Commit dcdc0682 by DigHuang Committed by GitHub

feat(sandbox): support runtime upgrade and advanced file/command operations (#7189)

* feat(sandbox): support edit-debug runtime image upgrade with workspace archive and restore

* feat(sandbox): support multi-select, absolute path copy, zip extraction and command execution

* feat(sandbox): support batch move with rollback, auto-naming zip extract, and debug sandbox rebuild fallback

* refactor(app): generalize ProModal and apply to sandbox upgrade modal

* fix(sandbox): prevent data loss by removing unsafe silent restore fallbacks on edit-debug sandbox upgrade or start
parent 7c54cd6b
---
title: 'V4.15.0-beta5 (In Progress)'
title: 'V4.15.0-beta5'
description: 'FastGPT V4.15.0-beta5 Release Notes'
---
## 📦 Upgrade Guide
### 1. Update Environment Variables
Add the `CHAT_TITLE_MODEL` environment variable to `fastgpt` and `fastgpt-pro`. It is used to automatically generate chat titles. For example:
```shell
CHAT_TITLE_MODEL=deepseek-v4-flash
```
If Agent Sandbox is enabled, also add the following environment variables to `fastgpt`:
```shell
# Shared with fastgpt-agent-sandbox-proxy. In production, replace it with a random secret longer than 32 characters.
AGENT_SANDBOX_PROXY_SECRET=replace_with_32_chars_random_secret
# Browser-accessible WebSocket URL for agent-sandbox-proxy. Use wss:// if it is proxied through an HTTPS domain.
AGENT_SANDBOX_PROXY_URL=ws://{{host}}:1006
```
### 2. Image Changes
- Update the fastgpt-app (FastGPT main service) image tag to v4.15.0-beta5.
- Update the fastgpt-pro (FastGPT commercial edition) image tag to v4.15.0-beta5.
- Update the fastgpt-plugin image tag to v1.0.0-beta5.
- Update the aiproxy image tag to v0.6.2.
If Agent Sandbox is enabled, also update the following images:
- Add the fastgpt-agent-sandbox-proxy image with tag v0.2.0-beta3.
- Update the fastgpt-agent-sandbox image tag to v0.2.0-beta3.
Also add the `fastgpt-agent-sandbox-proxy` service to `docker-compose.yml`. The example below uses the China Mainland image registry. For global deployments, change the image to `ghcr.io/labring/fastgpt-agent-sandbox-proxy:v0.2.0-beta3`:
```yml
fastgpt-agent-sandbox-proxy:
image: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox-proxy:v0.2.0-beta3
container_name: fastgpt-agent-sandbox-proxy
restart: always
ports:
- 1006:1006
networks:
- fastgpt
environment:
PORT: 1006
# Must exactly match AGENT_SANDBOX_PROXY_SECRET in fastgpt.
AGENT_SANDBOX_PROXY_SECRET: replace_with_32_chars_random_secret
# Internal URL of the main app container. If your service name is not fastgpt, update it accordingly.
FASTGPT_APP_URL: http://fastgpt:3000
FASTGPT_APP_REQUEST_TIMEOUT_SECS: 10
RUST_LOG: info,fastgpt_agent_sandbox_proxy=debug
# Configure this only when the upstream sandbox endpoint returns localhost/127.0.0.1 and the proxy container cannot reach it.
# AGENT_SANDBOX_PROXY_REWRITE_HOST: host.docker.internal
```
### 3. Upgrade Script
Archive all old sandbox workspaces to S3 to more thoroughly release inactive sandboxes. Some old sandboxes may fail to install zip packages because of timeouts. Because most old sandboxes are tied to old chats, you may also remove all old sandboxes directly instead of running this script. This script only affects old sandboxes and does not affect newly created sandboxes.
```shell
curl --location --request POST 'https://{{host}}/api/admin/initSandboxArchive' \
--header 'rootkey: {{rootkey}}' \
--header 'Content-Type: application/json' \
-d '{"runArchive":true,"inactiveDays":0}'
```
## Breaking Changes
1. API Key behavior has changed. FastGPT no longer distinguishes between app keys and system keys; only system keys are kept. For OpenAI SDK compatibility, pass the token as `apikey-appId`. Existing API keys remain compatible and continue to work. For details, see the [FastGPT API documentation](../../../openapi/intro).
......@@ -10,12 +75,34 @@ description: 'FastGPT V4.15.0-beta5 Release Notes'
## 🚀 New Features
1. The HTTP node now supports ignoring TLS certificate verification, which is useful when calling HTTPS services that use self-signed or internal certificates.
2. Added an environment variable for maximum folder depth to prevent unlimited nested folders.
3. Chat windows now support a quick scroll-to-bottom button.
4. Optimized streaming output animations based on Lobe UI.
5. Added model-generated chat titles. Configure the `CHAT_TITLE_MODEL` variable to enable this feature.
6. Adjusted the Skill Edit editing experience.
7. The HTTP node now supports returning the complete error object.
8. Knowledge Base search in agent mode now supports permission filtering.
9. Optimized API key logic by unifying APIKey management and requiring requests to explicitly pass the app context.
10. Optimized agent context compression.
11. Added output syntax for quick replies.
## ⚙️ Improvements
1. HTML output now automatically switches to preview mode after generation, reducing the need to open the preview manually.
2. Improved long-name display for apps, datasets, files, and folders: names are truncated when they exceed the available width, and the full name is shown when hovering over the name.
2. Improved long-name display for apps, Knowledge Bases, files, and folders: names are truncated when they exceed the available width, and the full name is shown on hover.
3. Removed `temperature` and `max_tokens` from all built-in LLM requests to avoid incompatibility with some models.
4. Improved error prompts for Knowledge Base training failures, including one-click retry for all failed items.
5. Filtered out invalid Knowledge Base citation markers.
6. When a tool returns an empty response, FastGPT now automatically fills in `"none"` to avoid errors from some models.
7. Added a second permission check before system tools run.
8. Optimized SSRF checks after redirects.
## 🐛 Bug Fixes
1. Fixed a potential cross-resource file access risk when private S3 object keys were not bound to the already-authorized resource.
2. Fixed abnormal tool call parameter schemas for `array` and `object` types in Workflow tools.
3. Fixed a UI offset issue in the portal publish channel.
## Code Improvements
1. Added a length guard for system string processing. When the string is too long, synchronous replacement stops to avoid high CPU load. You can adjust the limit with the `SYSTEM_MAX_STRING_LENGTH_M` environment variable.
......@@ -13,6 +13,15 @@ description: 'FastGPT V4.15.0-beta5 更新说明'
CHAT_TITLE_MODEL=deepseek-v4-flash
```
如果启用 Agent Sandbox,`fastgpt` 还需要增加下面环境变量:
```shell
# 与 fastgpt-agent-sandbox-proxy 共用,生产环境请改为 32 位以上随机密钥
AGENT_SANDBOX_PROXY_SECRET=replace_with_32_chars_random_secret
# 浏览器可访问的 agent-sandbox-proxy WebSocket 地址;如已通过 HTTPS 域名代理,请使用 wss://
AGENT_SANDBOX_PROXY_URL=ws://{{host}}:1006
```
### 2. 镜像变更
- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.15.0-beta5
......@@ -20,6 +29,34 @@ CHAT_TITLE_MODEL=deepseek-v4-flash
- 更新 fastgpt-plugin 镜像 tag: v1.0.0-beta5
- 更新 aiproxy 镜像 tag: v0.6.2
如果启用 Agent Sandbox,需同步更新下面镜像:
- 新增 fastgpt-agent-sandbox-proxy 镜像 tag: v0.2.0-beta3
- 更新 fastgpt-agent-sandbox 镜像 tag: v0.2.0-beta3
同时在 `docker-compose.yml` 中新增 `fastgpt-agent-sandbox-proxy` 服务。下面示例使用国内镜像源,海外部署可将镜像改为 `ghcr.io/labring/fastgpt-agent-sandbox-proxy:v0.2.0-beta3`:
```yml
fastgpt-agent-sandbox-proxy:
image: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox-proxy:v0.2.0-beta3
container_name: fastgpt-agent-sandbox-proxy
restart: always
ports:
- 1006:1006
networks:
- fastgpt
environment:
PORT: 1006
# 必须与 fastgpt 中的 AGENT_SANDBOX_PROXY_SECRET 完全一致
AGENT_SANDBOX_PROXY_SECRET: replace_with_32_chars_random_secret
# 主站容器内网地址;如果服务名不是 fastgpt,请按实际 docker-compose 服务名调整
FASTGPT_APP_URL: http://fastgpt:3000
FASTGPT_APP_REQUEST_TIMEOUT_SECS: 10
RUST_LOG: info,fastgpt_agent_sandbox_proxy=debug
# 当上游 sandbox endpoint 返回 localhost/127.0.0.1 且 proxy 容器无法访问时再配置
# AGENT_SANDBOX_PROXY_REWRITE_HOST: host.docker.internal
```
### 3. 升级脚本
将所有旧的沙盒 workspace 归档到 s3 里,从而更彻底的释放不活跃的沙盒,旧的沙盒可能因为超时安装 zip 失败。因为旧的沙盒大部分关联的是旧的对话,不执行该脚本,直接把旧的沙盒全部移除也可以。该脚本仅影响旧的沙盒,不影响新生成沙盒。
......
......@@ -6,7 +6,8 @@ const startCode = 510000;
export enum SandboxErrEnum {
agentSandboxPermissionDenied = 'agentSandboxPermissionDenied',
agentSandboxInitializing = 'agentSandboxInitializing'
agentSandboxInitializing = 'agentSandboxInitializing',
runtimeUpgradeFailed = 'runtimeUpgradeFailed'
}
const sandboxErr = [
......@@ -18,6 +19,10 @@ const sandboxErr = [
statusText: SandboxErrEnum.agentSandboxInitializing,
message: i18nT('common:code_error.sandbox_error.agent_sandbox_initializing'),
httpStatus: 409
},
{
statusText: SandboxErrEnum.runtimeUpgradeFailed,
message: i18nT('common:code_error.sandbox_error.runtime_upgrade_failed')
}
];
......
......@@ -5,3 +5,6 @@ export const getTextValidLength = (chunk: string) => {
export const isObjectId = (str: string) => {
return /^[0-9a-fA-F]{24}$/.test(str);
};
/** Shell 单参数安全转义,用于拼接传给 sandbox 的命令。 */
export const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
......@@ -57,6 +57,10 @@ export type SandboxStatusPhase =
| 'downloadingPackage' // downloading skill package from MinIO
| 'uploadingPackage' // uploading package into sandbox container
| 'extractingPackage' // extracting package in sandbox
// Runtime image upgrade phases
| 'runtimeUpgradeRequired' // existing edit-debug sandbox uses an outdated runtime image
| 'runtimeUpgradeArchiving' // archiving workspace before recreating with current image
| 'runtimeUpgradeArchived' // outdated runtime is archived or removed; caller should refresh/restart
// Lazy-init phases
| 'lazyInit' // LLM first calls sandbox tool, triggers container creation
// Terminal phases
......
......@@ -152,7 +152,8 @@ export type ImportSkillResponse = z.infer<typeof ImportSkillResponseSchema>;
export const CreateEditDebugSandboxBodySchema = z.object({
skillId: IdSchema,
image: SandboxImageConfigSchema.optional()
image: SandboxImageConfigSchema.optional(),
archiveForUpgrade: z.boolean().optional()
});
export type CreateEditDebugSandboxBody = z.infer<typeof CreateEditDebugSandboxBodySchema>;
......
......@@ -325,7 +325,32 @@ export async function markSandboxArchiving(resource: SandboxResourceDoc, inactiv
},
{
$set: {
'metadata.archive.state': 'archiving'
'metadata.archive.state': 'archiving',
'metadata.archive.startedAt': new Date()
}
},
{ new: true }
).lean<SandboxResourceDoc | null>();
}
/**
* 用户主动升级 edit-debug runtime 时抢占实例进行归档。
*
* 该入口不依赖 inactive/stopped 判断,因为升级由 Skill detail 显式触发;
* 但仍使用 archive state CAS,避免与恢复、定时归档或重复点击并发抢同一条记录。
*/
export async function markSandboxArchivingForRuntimeUpgrade(resource: SandboxResourceDoc) {
return MongoSandboxInstance.findOneAndUpdate(
{
...buildSandboxResourceRecordFilter(resource),
lastActiveAt: resource.lastActiveAt,
'metadata.archive.state': { $exists: false }
},
{
$set: {
status: SandboxStatusEnum.stopped,
'metadata.archive.state': 'archiving',
'metadata.archive.startedAt': new Date()
}
},
{ new: true }
......@@ -395,6 +420,42 @@ export async function clearSandboxArchiveState(resource: SandboxResourceRef) {
}
/**
* 清理用户升级归档的中间状态,并恢复抢占归档前的本地 status。
*
* 升级归档会临时把 running 实例标为 stopped 来复用 archived CAS;失败时必须恢复原状态,
* 否则用户重试前 Mongo 记录会短暂呈现为 stopped。
*/
export async function clearSandboxRuntimeUpgradeArchiveState(resource: SandboxResourceDoc) {
return MongoSandboxInstance.updateOne(
{
...buildSandboxResourceRecordFilter(resource),
'metadata.archive.state': 'archiving'
},
{
$set: {
status: resource.status
},
$unset: {
'metadata.archive': ''
}
}
);
}
/**
* 清理卡在 runtime 升级归档中的 edit-debug 记录。
*
* 只允许处理仍处于 archiving 的同一条资源;调用方随后会删除远端资源并从当前发布包重建。
* 这里直接删除 Mongo 记录,避免刷新后继续命中 archiving 状态。
*/
export async function deleteStaleRuntimeUpgradeArchivingRecord(resource: SandboxResourceDoc) {
return MongoSandboxInstance.deleteOne({
...buildSandboxResourceRecordFilter(resource),
'metadata.archive.state': 'archiving'
});
}
/**
* 原子抢占一个已归档实例进行恢复。
*/
export async function markSandboxRestoring(resource: SandboxResourceRef) {
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
import { isRedisLeaseError, withRedisLease } from '../../../../common/redis/lock';
import { createAgentSandboxInitializingError } from '../error';
import type { SandboxPrepareContext, SandboxPrepareStep } from './prepare';
import { buildRuntimeHash, shellQuote } from './utils';
import { buildRuntimeHash } from './utils';
import {
getRuntimeStateValue,
readSandboxRuntimeState,
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
import { buildRuntimeHash, joinSandboxPath, shellQuote } from './utils';
import { buildRuntimeHash, joinSandboxPath } from './utils';
import {
getRuntimeStateValue,
readSandboxRuntimeState,
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import {
injectInputFilesToSandbox,
readSandboxPwd,
......@@ -6,7 +7,6 @@ import {
type SandboxInputFile
} from './files';
import { prepareSandboxRuntimeMirrors } from './mirrors';
import { shellQuote } from './utils';
export type SandboxPrepareContext = {
sandbox: ISandbox;
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import { resolveSandboxHome } from './home';
import { joinSandboxPath, shellQuote } from './utils';
import { joinSandboxPath } from './utils';
const logger = getLogger(LogCategories.MODULE.AI.AGENT);
......
......@@ -2,9 +2,6 @@ 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(/\/+$/, '');
......
import { getErrText } from '@fastgpt/global/common/error/utils';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { batchRun } from '@fastgpt/global/common/system/utils';
import { subDays } from 'date-fns';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
......@@ -10,7 +11,7 @@ import { serviceEnv } from '../../../../env';
import { getSandboxAdapterConfig } from '../provider/config';
import { connectToSandbox, disconnectSandbox } from '../provider/lifecycle';
import { getSandboxRuntimeProfile } from '../runtime/profile';
import { joinSandboxPath, shellQuote } from '../runtime/utils';
import { joinSandboxPath } from '../runtime/utils';
import {
deleteSessionVolume,
getSessionVolumeConfig,
......@@ -18,11 +19,13 @@ import {
} from '../volume/service';
import {
clearSandboxArchiveState,
clearSandboxRuntimeUpgradeArchiveState,
createSandboxResourcesToArchiveCursor,
findSandboxInstanceArchiveState,
isSandboxStillArchiving,
markSandboxArchived,
markSandboxArchiving,
markSandboxArchivingForRuntimeUpgrade,
markSandboxRestored,
markSandboxRestoring,
markSandboxResourceStopped,
......@@ -320,24 +323,57 @@ async function restoreWorkspaceArchive(params: {
logger.info('Sandbox workspace restored from archive', { sandboxId });
}
type SandboxArchiveFlowParams = {
archivingDoc: SandboxResourceDoc;
options?: Pick<SandboxArchiveOptions, 'ensureZipInSandbox'>;
logLabel: string;
rollbackResource: SandboxResourceDoc;
rollbackArchiveState: () => Promise<unknown>;
beforeRemoteDelete?: () => Promise<{ success: true } | { success: false; error: string }>;
};
/**
* 归档单个 sandbox 实例。
* 执行归档的公共流水线。
*
* inactiveBefore 是归档判断边界,调用方负责用同一个边界查询候选资源并执行归档,
* 避免同一轮任务中查询时间和二次活跃检查时间不一致。
* 不同入口只负责抢占记录、回滚策略和是否需要删除远端前的二次检查;
* zip workspace、上传 S3、删除远端资源、标记 archived 以及失败清理由这里统一维护。
*/
export async function archiveSandboxResource(
resource: SandboxResourceDoc,
inactiveBefore: Date,
options: SandboxArchiveOptions = {}
): Promise<{ success: boolean; error?: string }> {
const archivingDoc = await markSandboxArchiving(resource, inactiveBefore);
if (!archivingDoc) {
return { success: false, error: 'Resource was modified or occupied' };
}
async function runSandboxArchiveFlow({
archivingDoc,
options = {},
logLabel,
rollbackResource,
rollbackArchiveState,
beforeRemoteDelete
}: SandboxArchiveFlowParams): Promise<{ success: boolean; error?: string }> {
let connectedSandbox: ISandbox | undefined;
let remoteResourceDeleted = false;
const rollbackBeforeRemoteDelete = async (params: {
error: string;
deleteArchiveLog: string;
stopLog: string;
}) => {
await rollbackArchiveState();
await getS3SandboxSource()
.deleteWorkspaceArchive({
sandboxId: archivingDoc.sandboxId
})
.catch((error) => {
logger.error(params.deleteArchiveLog, {
sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider,
error
});
});
await stopTemporaryLiftedSandbox(rollbackResource).catch((error) => {
logger.error(params.stopLog, {
sandboxId: archivingDoc.sandboxId,
error
});
});
return { success: false, error: params.error };
};
try {
const { sandbox, profile } = await connectSandboxForArchive(archivingDoc);
connectedSandbox = sandbox;
......@@ -359,71 +395,35 @@ export async function archiveSandboxResource(
body: archiveBuffer
});
const stillArchiving = await isSandboxStillArchiving(archivingDoc, inactiveBefore);
if (!stillArchiving) {
await clearSandboxArchiveState(archivingDoc);
await getS3SandboxSource()
.deleteWorkspaceArchive({
sandboxId: archivingDoc.sandboxId
})
.catch((error) => {
logger.error('Failed to delete aborted sandbox archive', {
sandboxId: archivingDoc.sandboxId,
error
});
});
await stopTemporaryLiftedSandbox(archivingDoc).catch((error) => {
logger.error('Failed to stop temporary lifted sandbox after archive abort', {
sandboxId: archivingDoc.sandboxId,
error
});
const beforeRemoteDeleteResult = await beforeRemoteDelete?.();
if (beforeRemoteDeleteResult && !beforeRemoteDeleteResult.success) {
return rollbackBeforeRemoteDelete({
error: beforeRemoteDeleteResult.error,
deleteArchiveLog: `Failed to delete aborted ${logLabel} archive`,
stopLog: `Failed to stop temporary lifted sandbox after ${logLabel} archive abort`
});
return { success: false, error: 'Archive aborted because of subsequent user activity' };
}
try {
await deleteArchivedRemoteResource(archivingDoc);
remoteResourceDeleted = true;
} catch (error: unknown) {
logger.error('Failed to cleanup archived sandbox remote resource', {
logger.error(`Failed to cleanup archived ${logLabel} remote resource`, {
sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider,
error
});
const errorMessage = getErrText(error);
await getS3SandboxSource()
.deleteWorkspaceArchive({
sandboxId: archivingDoc.sandboxId
})
.catch((deleteError) => {
logger.error('Failed to delete sandbox archive after remote cleanup failure', {
sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider,
error: deleteError
});
});
await clearSandboxArchiveState(archivingDoc).catch((clearError) => {
logger.error('Failed to clear archive state after remote cleanup failure', {
sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider,
error: clearError
});
});
await stopTemporaryLiftedSandbox(archivingDoc).catch((stopError) => {
logger.error('Failed to stop temporary lifted sandbox after remote cleanup failure', {
sandboxId: archivingDoc.sandboxId,
error: stopError
});
return rollbackBeforeRemoteDelete({
error: `Failed to delete remote resource: ${errorMessage}`,
deleteArchiveLog: `Failed to delete ${logLabel} archive after remote cleanup failure`,
stopLog: `Failed to stop temporary lifted sandbox after ${logLabel} remote cleanup failure`
});
return {
success: false,
error: `Failed to delete remote resource: ${errorMessage}`
};
}
const archivedResult = await markSandboxArchived(archivingDoc);
if (archivedResult.matchedCount === 0) {
logger.error('Sandbox archive state changed after remote resource deletion', {
logger.error(`${logLabel} archive state changed after remote resource deletion`, {
sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider
});
......@@ -437,13 +437,13 @@ export async function archiveSandboxResource(
const errorMessage = getErrText(error);
if (remoteResourceDeleted) {
await markSandboxArchived(archivingDoc).catch((markError) => {
logger.error('Failed to mark sandbox archived after remote resource deletion', {
logger.error(`Failed to mark ${logLabel} archived after remote resource deletion`, {
sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider,
error: markError
});
});
logger.error('Failed to archive sandbox after remote resource deletion', {
logger.error(`Failed to archive ${logLabel} after remote resource deletion`, {
sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider,
error
......@@ -451,14 +451,14 @@ export async function archiveSandboxResource(
return { success: false, error: errorMessage };
}
await clearSandboxArchiveState(archivingDoc);
await stopTemporaryLiftedSandbox(archivingDoc).catch((stopError) => {
logger.error('Failed to stop temporary lifted sandbox after archive failure', {
await rollbackArchiveState();
await stopTemporaryLiftedSandbox(rollbackResource).catch((stopError) => {
logger.error(`Failed to stop temporary lifted sandbox after ${logLabel} archive failure`, {
sandboxId: archivingDoc.sandboxId,
error: stopError
});
});
logger.error('Failed to archive sandbox', {
logger.error(`Failed to archive ${logLabel}`, {
sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider,
error
......@@ -472,6 +472,61 @@ export async function archiveSandboxResource(
}
/**
* 归档单个 sandbox 实例。
*
* inactiveBefore 是归档判断边界,调用方负责用同一个边界查询候选资源并执行归档,
* 避免同一轮任务中查询时间和二次活跃检查时间不一致。
*/
export async function archiveSandboxResource(
resource: SandboxResourceDoc,
inactiveBefore: Date,
options: SandboxArchiveOptions = {}
): Promise<{ success: boolean; error?: string }> {
const archivingDoc = await markSandboxArchiving(resource, inactiveBefore);
if (!archivingDoc) {
return { success: false, error: 'Resource was modified or occupied' };
}
return runSandboxArchiveFlow({
archivingDoc,
options,
logLabel: 'sandbox',
rollbackResource: archivingDoc,
rollbackArchiveState: () => clearSandboxArchiveState(archivingDoc),
beforeRemoteDelete: async () => {
const stillArchiving = await isSandboxStillArchiving(archivingDoc, inactiveBefore);
return stillArchiving
? { success: true }
: { success: false, error: 'Archive aborted because of subsequent user activity' };
}
});
}
/**
* 用户点击升级 edit-debug runtime 时归档旧实例。
*
* 该流程复用 workspace zip/upload/delete/mark archived 能力,但不做 inactive 二次检查;
* 调用方已经确认这是当前 Skill detail 的旧 runtime 实例,归档完成后由常规启动流程恢复到新镜像。
*/
export async function archiveSandboxResourceForRuntimeUpgrade(
resource: SandboxResourceDoc,
options: Pick<SandboxArchiveOptions, 'ensureZipInSandbox'> = {}
): Promise<{ success: boolean; error?: string }> {
const archivingDoc = await markSandboxArchivingForRuntimeUpgrade(resource);
if (!archivingDoc) {
return { success: false, error: 'Resource was modified or occupied' };
}
return runSandboxArchiveFlow({
archivingDoc,
options,
logLabel: 'upgraded sandbox',
rollbackResource: resource,
rollbackArchiveState: () => clearSandboxRuntimeUpgradeArchiveState(resource)
});
}
/**
* 流式读取待归档 sandbox,并按固定批次执行实际归档。
*
* cursor 的 batchSize 控制 Mongo 单次返回量;归档 batch 控制远端资源同时拉起数量。
......
......@@ -44,6 +44,7 @@ export const SandboxMetadataSchema = z.object({
archive: z
.object({
state: SandboxArchiveStateSchema,
startedAt: z.coerce.date().optional(),
archivedAt: z.coerce.date().optional()
})
.optional(),
......
import { getErrText } from '@fastgpt/global/common/error/utils';
import { getErrText, UserError } from '@fastgpt/global/common/error/utils';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import type { ISandbox, SandboxCreateSpec } from '@fastgpt-sdk/sandbox-adapter';
import { MongoAgentSkills } from '../model/schema';
import { MongoAgentSkillsVersion } from '../version/schema';
import { parseGitignoreRules } from '../utils';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import { joinSandboxPath } from '../../sandbox/runtime/utils';
import {
DEFAULT_GITIGNORE_CONTENT,
validateDeployableSkillWorkspacePackage,
......@@ -19,6 +20,7 @@ import { getSandboxRuntimeProfile } from '../../sandbox/runtime/profile';
import type { SandboxImageConfigType } from '@fastgpt/global/core/ai/skill/type';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { SandboxErrEnum } from '@fastgpt/global/common/error/code/sandbox';
import {
connectReadySandboxByInstance,
connectToSandbox,
......@@ -29,13 +31,14 @@ import { buildSandboxAdapter } from '../../sandbox/provider/adapter';
import type { SandboxClient } from '../../sandbox/service/runtime';
import { getSandboxClient } from '../../sandbox/service/runtime';
import { deleteSandboxResource } from '../../sandbox/service/resource';
import { archiveSandboxResourceForRuntimeUpgrade } from '../../sandbox/service/archive';
import {
countRunningSandboxInstancesByType,
deleteSandboxInstanceRecord,
findSandboxInstanceBySandboxId,
findSandboxResourcesBySourceChatTypeExcludeProvider,
migrateArchivedSandboxInstanceRecord,
updateSandboxInstanceRecordBySandboxId
updateSandboxInstanceRecordBySandboxId,
type SandboxResourceDoc
} from '../../sandbox/instance/repository';
import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
......@@ -57,6 +60,8 @@ import {
} from '../runtime/prepare';
const addLog = getLogger(LogCategories.MODULE.AI.AGENT);
const RUNTIME_UPGRADE_FAILED_MESSAGE = SandboxErrEnum.runtimeUpgradeFailed;
const RUNTIME_UPGRADE_ARCHIVING_TIMEOUT_MS = 10 * 60 * 1000;
export type CreateEditDebugSandboxParams = {
skillId: string;
......@@ -64,6 +69,7 @@ export type CreateEditDebugSandboxParams = {
tmbId: string;
image?: SandboxImageConfigType;
entrypoint?: SandboxCreateSpec['entrypoint'];
archiveForUpgrade?: boolean;
onProgress?: (status: SandboxStatusItemType) => void;
};
......@@ -85,7 +91,7 @@ export type CreateEditDebugSandboxResult = {
export async function createEditDebugSandbox(
params: CreateEditDebugSandboxParams
): Promise<CreateEditDebugSandboxResult> {
const { skillId, teamId, tmbId, image, entrypoint, onProgress } = params;
const { skillId, teamId, tmbId, image, entrypoint, archiveForUpgrade, onProgress } = params;
try {
await checkTeamSandboxPermission(teamId);
......@@ -180,56 +186,171 @@ export async function createEditDebugSandbox(
sandboxId: existingInstance.sandboxId
}
: null;
const shouldCleanWorkspaceBeforeDeploy = !!existingInstance;
const forceCleanupStaleSandbox = async (instance: NonNullable<typeof existingInstance>) => {
try {
await deleteSandboxResource(instance, { keepVolume: true });
} catch (deleteError) {
addLog.error('[Sandbox] Failed to delete unavailable sandbox resource', {
sandboxId: instance.sandboxId,
error: deleteError
});
}
await deleteSandboxInstanceRecord(instance._id);
const finishRuntimeUpgradePreparation = (sandboxId: string) => {
onProgress?.({
sandboxId,
phase: 'runtimeUpgradeArchived'
});
return {
sandboxId,
status: {
state: 'UpgradePrepared'
}
};
};
/**
* edit-debug 是可从当前 skill package 重建的临时工作区。
* 当历史归档记录存在但 S3 对象已缺失时,只在本入口降级重建,避免普通运行态 sandbox 静默丢数据。
*/
const isMissingSandboxArchiveError = (error: unknown): boolean => {
const pending: unknown[] = [error];
const visited = new Set<unknown>();
while (pending.length > 0) {
const current = pending.shift();
if (!current || typeof current !== 'object' || visited.has(current)) continue;
visited.add(current);
const errorLike = current as Error & {
code?: unknown;
Code?: unknown;
cause?: unknown;
commandError?: unknown;
const failRuntimeUpgrade = (sandboxId: string): never => {
onProgress?.({
sandboxId,
phase: 'failed',
message: RUNTIME_UPGRADE_FAILED_MESSAGE
});
throw new UserError(RUNTIME_UPGRADE_FAILED_MESSAGE);
};
const isRuntimeUpgradeArchivingTimedOut = (instance: SandboxResourceDoc) => {
const startedAt = instance.metadata?.archive?.startedAt ?? instance.lastActiveAt;
return Date.now() - startedAt.getTime() > RUNTIME_UPGRADE_ARCHIVING_TIMEOUT_MS;
};
const normalizeImage = (image?: SandboxImageConfigType | string | null) => {
if (typeof image === 'string') {
const lastColonIndex = image.lastIndexOf(':');
if (lastColonIndex > 0 && !image.slice(lastColonIndex + 1).includes('/')) {
return {
repository: image.slice(0, lastColonIndex),
tag: image.slice(lastColonIndex + 1)
};
}
return {
repository: image,
tag: ''
};
const errorName = errorLike.name;
const errorCode = errorLike.code ?? errorLike.Code;
const message = errorLike.message ?? '';
if (
errorName === 'NoSuchKey' ||
errorCode === 'NoSuchKey' ||
(message.includes('NoSuchKey') && message.includes('specified key does not exist'))
) {
return true;
}
if (!image?.repository) return undefined;
const repository = image.repository;
const tag = image.tag ?? '';
if (!tag) {
const lastColonIndex = repository.lastIndexOf(':');
if (lastColonIndex > 0 && !repository.slice(lastColonIndex + 1).includes('/')) {
return {
repository: repository.slice(0, lastColonIndex),
tag: repository.slice(lastColonIndex + 1)
};
}
pending.push(errorLike.cause, errorLike.commandError);
}
return {
repository,
tag
};
};
return false;
const runtimeImage = normalizeImage(createConfig.image);
const isRuntimeImageMatched = (existingImage?: SandboxImageConfigType | string | null) => {
const normalizedExistingImage = normalizeImage(existingImage);
return (
!runtimeImage ||
(!!normalizedExistingImage &&
normalizedExistingImage.repository === runtimeImage.repository &&
normalizedExistingImage.tag === runtimeImage.tag)
);
};
const staleProviderInstances = await findSandboxResourcesBySourceChatTypeExcludeProvider({
provider: providerConfig.provider,
sourceType: ChatSourceTypeEnum.skillEdit,
sourceId: skillId,
chatId: EDIT_DEBUG_SANDBOX_CHAT_ID,
type: SandboxTypeEnum.editDebug
});
const staleRuntimeUpgradeInstance =
!existingInstance && !shouldRecoverArchivedInstance
? staleProviderInstances.find(
(instance) =>
instance.metadata?.archive?.state === undefined &&
!isRuntimeImageMatched(instance.metadata?.image)
)
: undefined;
const runtimeUpgradeInstance =
existingInstance && !shouldRecoverArchivedInstance
? existingInstance
: staleRuntimeUpgradeInstance;
const requiresRuntimeImageUpgrade =
!!runtimeUpgradeInstance && !isRuntimeImageMatched(runtimeUpgradeInstance.metadata?.image);
if (requiresRuntimeImageUpgrade && runtimeUpgradeInstance) {
addLog.info('[Sandbox] Edit-debug sandbox runtime image upgrade required', {
sandboxId: runtimeUpgradeInstance.sandboxId,
existingImage: runtimeUpgradeInstance.metadata?.image,
runtimeImage
});
if (!archiveForUpgrade) {
onProgress?.({
sandboxId: runtimeUpgradeInstance.sandboxId,
phase: 'runtimeUpgradeRequired'
});
return {
sandboxId: runtimeUpgradeInstance.sandboxId,
status: {
state: 'UpgradeRequired'
}
};
}
onProgress?.({
sandboxId: runtimeUpgradeInstance.sandboxId,
phase: 'runtimeUpgradeArchiving'
});
/**
* edit-debug runtime 升级必须先成功归档旧 workspace。
*
* 归档失败时不能删除旧实例并回退到当前发布包重建,否则会丢弃用户在编辑态 sandbox
* 中尚未发布的文件变更。此时阻断升级,由前端引导用户重试或联系支持处理。
*/
const archiveResult = await archiveSandboxResourceForRuntimeUpgrade(runtimeUpgradeInstance, {
ensureZipInSandbox: true
});
if (!archiveResult.success) {
const message = archiveResult.error || 'Failed to archive outdated sandbox';
addLog.warn('[Sandbox] Failed to archive outdated edit-debug sandbox for runtime upgrade', {
sandboxId: runtimeUpgradeInstance.sandboxId,
error: message
});
failRuntimeUpgrade(runtimeUpgradeInstance.sandboxId);
}
return finishRuntimeUpgradePreparation(runtimeUpgradeInstance.sandboxId);
}
if (existingInstance && existingArchiveState === 'archiving') {
onProgress?.({
sandboxId: existingInstance.sandboxId,
phase: 'runtimeUpgradeArchiving'
});
if (!isRuntimeUpgradeArchivingTimedOut(existingInstance)) {
return {
sandboxId: existingInstance.sandboxId,
status: {
state: 'UpgradeInProgress'
}
};
}
addLog.warn('[Sandbox] Runtime upgrade archive timed out', {
sandboxId: existingInstance.sandboxId,
provider: existingInstance.provider,
archiveStartedAt: existingInstance.metadata?.archive?.startedAt
});
failRuntimeUpgrade(existingInstance.sandboxId);
}
const prepareContext = (sandbox: ISandbox): SkillPackagePrepareContext => ({
sandbox,
workDirectory: runtimeProfile.workDirectory
......@@ -290,6 +411,10 @@ export async function createEditDebugSandbox(
}
const existingMetadata = instance.metadata || {};
const normalizedExistingMetadata = {
...existingMetadata,
...(runtimeImage ? { image: runtimeImage } : {})
};
await updateSandboxInstanceRecordBySandboxId({
provider: providerConfig.provider,
sandboxId: instance.sandboxId,
......@@ -298,9 +423,9 @@ export async function createEditDebugSandbox(
userId: '',
chatId: EDIT_DEBUG_SANDBOX_CHAT_ID,
metadata: preparedContext.workspaceHasContent
? existingMetadata
? normalizedExistingMetadata
: {
...existingMetadata,
...normalizedExistingMetadata,
versionId: targetVersionId,
storage: {
key: currentVersion.storageKey,
......@@ -319,15 +444,13 @@ export async function createEditDebugSandbox(
status: { state: 'Running' }
};
} catch (error) {
addLog.error('[Sandbox] Existing sandbox is unavailable, recreating edit-debug sandbox', {
addLog.error('[Sandbox] Existing sandbox is unavailable', {
sandboxId: instance.sandboxId,
error: getErrText(error),
stack: error instanceof Error ? error.stack : undefined
});
shouldUnzipFromS3 = true;
await forceCleanupStaleSandbox(instance);
return null;
throw error;
} finally {
if (sandbox) {
await disconnectSandbox(sandbox);
......@@ -366,6 +489,7 @@ export async function createEditDebugSandbox(
const existingMetadata = instance.metadata || {};
const newMetadata = {
...existingMetadata,
...(runtimeImage ? { image: runtimeImage } : {}),
versionId: currentVersion._id.toString(),
storage: {
key: currentVersion.storageKey,
......@@ -416,16 +540,12 @@ export async function createEditDebugSandbox(
return await reloadExistingEditDebugSandbox(existingInstance, connectedSandbox);
} catch (error) {
addLog.error(
'[Sandbox] Mismatched sandbox is offline or unavailable, falling back to full recreation',
{
sandboxId: existingInstance.sandboxId,
error: getErrText(error),
stack: error instanceof Error ? error.stack : undefined
}
);
await forceCleanupStaleSandbox(existingInstance);
// 让流程继续往下走全新创建流程
addLog.error('[Sandbox] Mismatched sandbox is offline or unavailable', {
sandboxId: existingInstance.sandboxId,
error: getErrText(error),
stack: error instanceof Error ? error.stack : undefined
});
throw error;
} finally {
if (connectedSandbox) {
try {
......@@ -445,52 +565,49 @@ export async function createEditDebugSandbox(
});
}
const staleProviderInstances = await findSandboxResourcesBySourceChatTypeExcludeProvider({
provider: providerConfig.provider,
sourceType: ChatSourceTypeEnum.skillEdit,
sourceId: skillId,
chatId: EDIT_DEBUG_SANDBOX_CHAT_ID,
type: SandboxTypeEnum.editDebug
});
if (staleProviderInstances.length > 0) {
addLog.info('[Sandbox] Removing stale edit-debug sandbox records for inactive provider', {
skillId,
provider: providerConfig.provider,
staleProviders: staleProviderInstances.map((item) => item.provider)
});
await Promise.all(
staleProviderInstances.map(async (instance) => {
if (instance.metadata?.archive?.state === undefined) {
await deleteSandboxResource(instance).catch((error) => {
addLog.error('[Sandbox] Failed to delete stale provider sandbox resource', {
sandboxId: instance.sandboxId,
provider: instance.provider,
error
const staleInstancesToCleanup = staleProviderInstances.filter(
(instance) => instance._id !== staleRuntimeUpgradeInstance?._id
);
if (staleInstancesToCleanup.length > 0) {
addLog.info('[Sandbox] Removing stale edit-debug sandbox records for inactive provider', {
skillId,
provider: providerConfig.provider,
staleProviders: staleInstancesToCleanup.map((item) => item.provider)
});
await Promise.all(
staleInstancesToCleanup.map(async (instance) => {
if (instance.metadata?.archive?.state === undefined) {
await deleteSandboxResource(instance).catch((error) => {
addLog.error('[Sandbox] Failed to delete stale provider sandbox resource', {
sandboxId: instance.sandboxId,
provider: instance.provider,
error
});
});
} else {
// edit-debug sandboxId 由 skillId + edit-debug 稳定生成,不随 provider 变化;只需迁移 Mongo 索引记录。
const migratedInstance = await migrateArchivedSandboxInstanceRecord({
source: instance,
provider: providerConfig.provider,
sourceType: ChatSourceTypeEnum.skillEdit,
sourceId: skillId,
userId: '',
chatId: EDIT_DEBUG_SANDBOX_CHAT_ID,
type: SandboxTypeEnum.editDebug
});
});
await deleteSandboxInstanceRecord(instance._id);
} else {
// edit-debug sandboxId 由 skillId + edit-debug 稳定生成,不随 provider 变化;只需迁移 Mongo 索引记录。
const migratedInstance = await migrateArchivedSandboxInstanceRecord({
source: instance,
provider: providerConfig.provider,
sourceType: ChatSourceTypeEnum.skillEdit,
sourceId: skillId,
userId: '',
chatId: EDIT_DEBUG_SANDBOX_CHAT_ID,
type: SandboxTypeEnum.editDebug
});
if (migratedInstance) {
shouldUnzipFromS3 = false;
archivedRestoreRecord = {
_id: migratedInstance._id,
provider: migratedInstance.provider,
sandboxId: migratedInstance.sandboxId
};
if (migratedInstance) {
shouldUnzipFromS3 = false;
archivedRestoreRecord = {
_id: migratedInstance._id,
provider: migratedInstance.provider,
sandboxId: migratedInstance.sandboxId
};
}
}
}
})
);
})
);
}
}
const maxEditDebug =
......@@ -526,29 +643,7 @@ export async function createEditDebugSandbox(
}
);
let client: SandboxClient;
try {
client = await createRuntimeSandboxClient();
} catch (error) {
if (!archivedRestoreRecord || !isMissingSandboxArchiveError(error)) {
throw error;
}
addLog.warn(
'[Sandbox] Archived edit-debug package is missing, rebuilding from skill package',
{
sandboxId: archivedRestoreRecord.sandboxId,
provider: archivedRestoreRecord.provider,
error
}
);
await deleteSandboxInstanceRecord(archivedRestoreRecord._id);
archivedRestoreRecord = null;
shouldUnzipFromS3 = true;
client = await createRuntimeSandboxClient();
}
const client = await createRuntimeSandboxClient();
sandboxClient = client;
sandbox = client.provider;
......@@ -560,36 +655,21 @@ export async function createEditDebugSandbox(
});
if (shouldUnzipFromS3) {
if (existingInstance) {
await prepareSandbox(
prepareContext(client.provider),
preparePackageMirrors(),
prepareWorkDirectory(),
downloadSkillPackageToContext({
storageKey: currentVersion.storageKey,
onProgress: reportProgress(sessionId)
}),
emptyWorkDirectory(),
deployDownloadedSkillPackage({
skillsRootPath: runtimeProfile.skillsRootPath,
onProgress: reportProgress(sessionId)
})
);
} else {
await prepareSandbox(
prepareContext(client.provider),
preparePackageMirrors(),
prepareWorkDirectory(),
downloadSkillPackageToContext({
storageKey: currentVersion.storageKey,
onProgress: reportProgress(sessionId)
}),
deployDownloadedSkillPackage({
skillsRootPath: runtimeProfile.skillsRootPath,
onProgress: reportProgress(sessionId)
})
);
}
const prepareSteps = [
preparePackageMirrors(),
prepareWorkDirectory(),
downloadSkillPackageToContext({
storageKey: currentVersion.storageKey,
onProgress: reportProgress(sessionId)
}),
...(shouldCleanWorkspaceBeforeDeploy ? [emptyWorkDirectory()] : []),
deployDownloadedSkillPackage({
skillsRootPath: runtimeProfile.skillsRootPath,
onProgress: reportProgress(sessionId)
})
];
await prepareSandbox(prepareContext(client.provider), ...prepareSteps);
} else {
addLog.info(
'[Sandbox] Skill sandbox resumed with existing volume, skip initial S3 download/unzip',
......@@ -616,7 +696,7 @@ export async function createEditDebugSandbox(
teamId,
tmbId,
sessionId,
...(sandboxInfo.image ? { image: sandboxInfo.image } : {}),
...(runtimeImage ? { image: runtimeImage } : {}),
providerCreatedAt: sandboxInfo.createdAt,
storage: {
key: currentVersion.storageKey,
......
import type { FileWriteEntry, ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
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 { buildRuntimeHash, joinSandboxPath } from '../../sandbox/runtime/utils';
import {
getRuntimeStateValue,
readSandboxRuntimeState,
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { MongoAgentSkills } from '../model/schema';
import { MongoAgentSkillsVersion } from '../version/schema';
import { downloadSkillPackage } from '../package';
......@@ -6,7 +7,7 @@ import { parseSkillMarkdown, getSkillsRootPath } from '../utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import type { DeployedSkillInfo, DeployedSkillVersion } from './types';
import { serviceEnv } from '../../../../env';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import { joinSandboxPath } from '../../sandbox/runtime/utils';
import { authSkillByTmbId } from '../../../../support/permission/skill/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import type { DeployedSkillVersion } from './types';
import {
buildLimitedOutputShellCommand,
executeEntrypointCommand
} from '../../sandbox/runtime/entrypoint';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import { joinSandboxPath } from '../../sandbox/runtime/utils';
import {
getRuntimeStateValue,
readSandboxRuntimeState,
......
import type { SandboxStatusPhase } from '@fastgpt/global/core/chat/type';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import type { SandboxPrepareContext, SandboxPrepareStep } from '../../sandbox/runtime/prepare';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import { joinSandboxPath } from '../../sandbox/runtime/utils';
import { serviceEnv } from '../../../../env';
import { DEFAULT_GITIGNORE_CONTENT, downloadSkillPackage } from '../package';
......
......@@ -3,7 +3,8 @@
*
* 这里只放无副作用的 SKILL.md 文本解析和模板拼装,不访问数据库、对象存储、sandbox 或 LLM。
*/
import { joinSandboxPath, shellQuote } from '../sandbox/runtime/utils';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { joinSandboxPath } from '../sandbox/runtime/utils';
/* ==================== YAML Frontmatter 解析 (原 skillMarkdown.ts) ==================== */
......
......@@ -17,8 +17,10 @@ import {
findSandboxResourcesBySourceChatIds,
findSkillRelatedSandboxResources,
isSandboxStillArchiving,
clearSandboxRuntimeUpgradeArchiveState,
markSandboxArchived,
markSandboxArchiving,
markSandboxArchivingForRuntimeUpgrade,
migrateArchivedSandboxInstanceRecord,
markSandboxRestored,
markSandboxRestoring,
......@@ -563,6 +565,52 @@ describe('sandbox instance helpers', () => {
expect(stored?.storage).toBeUndefined();
});
it('claims running records for runtime upgrade archive and restores original status on rollback', async () => {
const sandboxId = `instance-helper-${getNanoid()}`;
const doc = await MongoSandboxInstance.create({
provider: 'opensandbox',
sandboxId,
sourceType: ChatSourceTypeEnum.app,
sourceId: `instance-helper-${getNanoid()}`,
userId: 'user-1',
chatId: 'edit-debug',
type: SandboxTypeEnum.editDebug,
status: SandboxStatusEnum.running,
lastActiveAt: new Date('2026-01-01T00:00:00.000Z'),
createdAt: new Date(),
metadata: {
image: { repository: 'old-image' }
}
});
const archiving = await markSandboxArchivingForRuntimeUpgrade(doc);
expect(archiving).toMatchObject({
status: SandboxStatusEnum.stopped,
metadata: {
archive: {
state: 'archiving'
}
}
});
await expect(
markSandboxArchivingForRuntimeUpgrade({
...doc.toObject(),
lastActiveAt: new Date('2025-01-01T00:00:00.000Z')
} as SandboxResourceDoc)
).resolves.toBeNull();
await clearSandboxRuntimeUpgradeArchiveState(doc);
const stored = await MongoSandboxInstance.findOne({ sandboxId }).lean();
expect(stored).toMatchObject({
status: SandboxStatusEnum.running,
metadata: {
image: { repository: 'old-image' }
}
});
expect(stored?.metadata?.archive).toBeUndefined();
});
it('streams archive candidates by lastActiveAt descending', async () => {
const inactiveBefore = new Date('2026-02-01T00:00:00.000Z');
const appId = `instance-helper-${getNanoid()}`;
......
......@@ -18,11 +18,13 @@ const archiveMocks = vi.hoisted(() => ({
getSessionVolumeConfig: vi.fn(),
deleteSessionVolume: vi.fn(),
clearSandboxArchiveState: vi.fn(),
clearSandboxRuntimeUpgradeArchiveState: vi.fn(),
createSandboxResourcesToArchiveCursor: vi.fn(),
findSandboxInstanceArchiveState: vi.fn(),
isSandboxStillArchiving: vi.fn(),
markSandboxArchived: vi.fn(),
markSandboxArchiving: vi.fn(),
markSandboxArchivingForRuntimeUpgrade: vi.fn(),
markSandboxRestored: vi.fn(),
markSandboxRestoring: vi.fn(),
markSandboxResourceStopped: vi.fn(),
......@@ -69,11 +71,13 @@ vi.mock('@fastgpt/service/core/ai/sandbox/volume/service', () => ({
vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({
clearSandboxArchiveState: archiveMocks.clearSandboxArchiveState,
clearSandboxRuntimeUpgradeArchiveState: archiveMocks.clearSandboxRuntimeUpgradeArchiveState,
createSandboxResourcesToArchiveCursor: archiveMocks.createSandboxResourcesToArchiveCursor,
findSandboxInstanceArchiveState: archiveMocks.findSandboxInstanceArchiveState,
isSandboxStillArchiving: archiveMocks.isSandboxStillArchiving,
markSandboxArchived: archiveMocks.markSandboxArchived,
markSandboxArchiving: archiveMocks.markSandboxArchiving,
markSandboxArchivingForRuntimeUpgrade: archiveMocks.markSandboxArchivingForRuntimeUpgrade,
markSandboxRestored: archiveMocks.markSandboxRestored,
markSandboxRestoring: archiveMocks.markSandboxRestoring,
markSandboxResourceStopped: archiveMocks.markSandboxResourceStopped,
......@@ -87,6 +91,7 @@ vi.mock('@fastgpt/service/core/ai/sandbox/provider/adapter', () => ({
import {
archiveInactiveSandboxes,
archiveSandboxResource,
archiveSandboxResourceForRuntimeUpgrade,
restoreArchivedSandboxBeforeUse
} from '@fastgpt/service/core/ai/sandbox/service/archive';
......@@ -108,7 +113,7 @@ const createResource = (overrides: Partial<any> = {}) => ({
const createSandbox = () =>
({
execute: vi.fn(async (command: string, _options?: unknown) => ({
execute: vi.fn(async (command: string) => ({
stdout: command.includes('wc -l') ? '1\n' : command.includes("awk '{s+=$7}") ? '12\n' : '',
stderr: '',
exitCode: 0
......@@ -166,6 +171,7 @@ describe('sandbox archive service', () => {
archiveMocks.deleteWorkspaceArchive.mockResolvedValue(undefined);
archiveMocks.disconnectSandbox.mockResolvedValue(undefined);
archiveMocks.clearSandboxArchiveState.mockResolvedValue(undefined);
archiveMocks.clearSandboxRuntimeUpgradeArchiveState.mockResolvedValue(undefined);
archiveMocks.markSandboxArchived.mockResolvedValue({ matchedCount: 1, modifiedCount: 1 });
archiveMocks.isSandboxStillArchiving.mockResolvedValue(true);
archiveMocks.markSandboxRestored.mockImplementation(async (resource, params) => ({
......@@ -322,6 +328,44 @@ describe('sandbox archive service', () => {
expect(archiveMocks.markSandboxArchived).not.toHaveBeenCalled();
});
it('archives runtime upgrade resources without inactive checks', async () => {
const resource = createResource({
status: SandboxStatusEnum.running,
metadata: {
image: { repository: 'old-runtime' }
}
});
const archivingResource = {
...resource,
status: SandboxStatusEnum.stopped,
metadata: {
...resource.metadata,
archive: {
state: 'archiving'
}
}
};
const sandbox = createSandbox();
const remoteResource = { delete: vi.fn(async () => undefined), stop: vi.fn() };
archiveMocks.markSandboxArchivingForRuntimeUpgrade.mockResolvedValue(archivingResource);
archiveMocks.connectToSandbox.mockResolvedValue(sandbox);
archiveMocks.buildSandboxResourceAdapter.mockReturnValue(remoteResource);
const result = await archiveSandboxResourceForRuntimeUpgrade(resource, {
ensureZipInSandbox: true
});
expect(result).toEqual({ success: true });
expect(archiveMocks.markSandboxArchivingForRuntimeUpgrade).toHaveBeenCalledWith(resource);
expect(archiveMocks.isSandboxStillArchiving).not.toHaveBeenCalled();
expect(sandbox.execute).toHaveBeenCalledWith(expect.stringContaining('command -v zip'), {
timeoutMs: 600_000,
maxOutputBytes: 8 * 1024
});
expect(remoteResource.delete).toHaveBeenCalledTimes(1);
expect(archiveMocks.markSandboxArchived).toHaveBeenCalledWith(archivingResource);
});
it('restores archive from the current provider record before runtime use', async () => {
const archivedResource = createResource({
metadata: {
......
......@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
connectToSandbox: vi.fn(),
disconnectSandbox: vi.fn(),
deleteWorkspaceArchive: vi.fn(),
archiveSandboxResourceForRuntimeUpgrade: vi.fn(),
prepareSandboxRuntimeMirrors: vi.fn(),
logger: {
info: vi.fn(),
......@@ -95,6 +96,10 @@ vi.mock('@fastgpt/service/core/ai/sandbox/service/resource', () => ({
deleteSandboxResource: vi.fn()
}));
vi.mock('@fastgpt/service/core/ai/sandbox/service/archive', () => ({
archiveSandboxResourceForRuntimeUpgrade: mocks.archiveSandboxResourceForRuntimeUpgrade
}));
vi.mock('@fastgpt/service/core/ai/sandbox/runtime/mirrors', () => ({
prepareSandboxRuntimeMirrors: mocks.prepareSandboxRuntimeMirrors
}));
......@@ -107,6 +112,7 @@ vi.mock('@fastgpt/service/common/s3/sources/sandbox', () => ({
vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({
countRunningSandboxInstancesByType: vi.fn(),
deleteStaleRuntimeUpgradeArchivingRecord: vi.fn(),
deleteSandboxInstanceRecord: vi.fn(),
findSandboxInstanceBySandboxId: vi.fn(),
findSandboxResourcesBySourceChatTypeExcludeProvider: vi.fn(),
......@@ -140,12 +146,16 @@ import {
createEditDebugSandbox,
packageSkillInSandbox
} from '@fastgpt/service/core/ai/skill/edit/sandbox';
import { getReadySandboxInfo } from '@fastgpt/service/core/ai/sandbox/provider/lifecycle';
import {
connectReadySandboxByInstance,
getReadySandboxInfo
} from '@fastgpt/service/core/ai/sandbox/provider/lifecycle';
import { buildSandboxAdapter } from '@fastgpt/service/core/ai/sandbox/provider/adapter';
import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/service/runtime';
import { deleteSandboxResource } from '@fastgpt/service/core/ai/sandbox/service/resource';
import {
countRunningSandboxInstancesByType,
deleteStaleRuntimeUpgradeArchivingRecord,
deleteSandboxInstanceRecord,
findSandboxInstanceBySandboxId,
findSandboxResourcesBySourceChatTypeExcludeProvider,
......@@ -410,7 +420,7 @@ describe('createEditDebugSandbox', () => {
delete: vi.fn()
} as any);
vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({
image: 'test-image',
image: { repository: 'test-image' },
createdAt: new Date('2026-01-01T00:00:00.000Z'),
status: { state: 'Running' }
} as any);
......@@ -487,7 +497,7 @@ describe('createEditDebugSandbox', () => {
delete: vi.fn()
} as any);
vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({
image: 'test-image',
image: { repository: 'test-image' },
createdAt: new Date('2026-01-01T00:00:00.000Z'),
status: { state: 'Running' }
} as any);
......@@ -495,9 +505,6 @@ describe('createEditDebugSandbox', () => {
_id: 'doc-restored'
} as any);
const { connectReadySandboxByInstance } =
await import('@fastgpt/service/core/ai/sandbox/provider/lifecycle');
await expect(
createEditDebugSandbox({
skillId,
......@@ -527,24 +534,380 @@ describe('createEditDebugSandbox', () => {
expect(downloadSkillPackage).not.toHaveBeenCalled();
});
it('rebuilds edit-debug sandbox from skill package when archived S3 package is missing', async () => {
it('rejects when existing edit-debug sandbox is unavailable without cleanup or rebuild', async () => {
const skillId = 'skill-1';
const existingInstance = {
_id: 'existing-instance',
provider: 'test-provider',
sandboxId: `edit-debug-${skillId}`,
status: 'running',
metadata: {
versionId: 'version-1'
}
};
const unavailableError = new Error('Sandbox container does not exist physically');
vi.mocked(MongoAgentSkills.findOne).mockResolvedValueOnce({
_id: skillId,
name: '测试的',
currentVersionId: 'version-1'
} as any);
vi.mocked(MongoAgentSkillsVersion.findOne).mockResolvedValueOnce({
_id: 'version-1',
storageKey: 'storage-key'
} as any);
vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(existingInstance as any);
vi.mocked(findSandboxResourcesBySourceChatTypeExcludeProvider).mockResolvedValueOnce([]);
vi.mocked(buildSandboxAdapter).mockReturnValueOnce({
getInfo: vi.fn(async () => null)
} as any);
await expect(
createEditDebugSandbox({
skillId,
teamId: 'team-1',
tmbId: 'tmb-1'
})
).rejects.toThrow(unavailableError.message);
expect(deleteSandboxResource).not.toHaveBeenCalledWith(existingInstance);
expect(deleteSandboxInstanceRecord).not.toHaveBeenCalledWith(existingInstance._id);
expect(downloadSkillPackage).not.toHaveBeenCalled();
expect(getSandboxClient).not.toHaveBeenCalled();
expect(updateSandboxInstanceRecordBySandboxId).not.toHaveBeenCalled();
});
it('rejects when mismatched existing edit-debug sandbox cannot hot reload without cleanup or rebuild', async () => {
const skillId = 'skill-1';
const existingInstance = {
_id: 'existing-instance',
provider: 'test-provider',
sandboxId: `edit-debug-${skillId}`,
status: 'running',
metadata: {
versionId: 'old-version'
}
};
const connectError = new Error('connect failed');
vi.mocked(MongoAgentSkills.findOne).mockResolvedValueOnce({
_id: skillId,
name: '测试的',
currentVersionId: 'version-1'
} as any);
vi.mocked(MongoAgentSkillsVersion.findOne).mockResolvedValueOnce({
_id: 'version-1',
storageKey: 'storage-key'
} as any);
vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(existingInstance as any);
vi.mocked(findSandboxResourcesBySourceChatTypeExcludeProvider).mockResolvedValueOnce([]);
vi.mocked(connectReadySandboxByInstance).mockRejectedValueOnce(connectError);
await expect(
createEditDebugSandbox({
skillId,
teamId: 'team-1',
tmbId: 'tmb-1'
})
).rejects.toThrow(connectError);
expect(deleteSandboxResource).not.toHaveBeenCalledWith(existingInstance);
expect(deleteSandboxInstanceRecord).not.toHaveBeenCalledWith(existingInstance._id);
expect(downloadSkillPackage).not.toHaveBeenCalled();
expect(getSandboxClient).not.toHaveBeenCalled();
expect(updateSandboxInstanceRecordBySandboxId).not.toHaveBeenCalled();
});
it.each([
{
title: 'no image metadata',
metadata: { versionId: 'version-1' }
},
{
title: 'empty image metadata from provider',
metadata: { image: { repository: '' }, versionId: 'version-1' }
}
])(
'reports runtime upgrade requirement when existing edit-debug sandbox has $title',
async ({ metadata }) => {
const skillId = 'skill-1';
const onProgress = vi.fn();
const existingInstance = {
_id: 'existing-instance',
provider: 'test-provider',
sandboxId: `edit-debug-${skillId}`,
status: 'running',
lastActiveAt: new Date('2026-01-01T00:00:00.000Z'),
metadata
};
vi.mocked(MongoAgentSkills.findOne).mockResolvedValueOnce({
_id: skillId,
name: '测试的',
currentVersionId: 'version-1'
} as any);
vi.mocked(MongoAgentSkillsVersion.findOne).mockResolvedValueOnce({
_id: 'version-1',
storageKey: 'storage-key'
} as any);
vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(existingInstance as any);
await expect(
createEditDebugSandbox({
skillId,
teamId: 'team-1',
tmbId: 'tmb-1',
image: { repository: 'new-runtime', tag: 'v2' },
onProgress
})
).resolves.toMatchObject({
sandboxId: `edit-debug-${skillId}`,
status: { state: 'UpgradeRequired' }
});
expect(onProgress).toHaveBeenCalledWith({
sandboxId: `edit-debug-${skillId}`,
phase: 'runtimeUpgradeRequired'
});
expect(mocks.archiveSandboxResourceForRuntimeUpgrade).not.toHaveBeenCalled();
expect(getSandboxClient).not.toHaveBeenCalled();
}
);
it('archives outdated edit-debug sandbox when runtime upgrade is confirmed', async () => {
const skillId = 'skill-1';
const onProgress = vi.fn();
const existingInstance = {
_id: 'existing-instance',
provider: 'test-provider',
sandboxId: `edit-debug-${skillId}`,
status: 'running',
lastActiveAt: new Date('2026-01-01T00:00:00.000Z'),
metadata: {
image: { repository: 'old-runtime', tag: 'v1' },
versionId: 'version-1'
}
};
vi.mocked(MongoAgentSkills.findOne).mockResolvedValueOnce({
_id: skillId,
name: '测试的',
currentVersionId: 'version-1'
} as any);
vi.mocked(MongoAgentSkillsVersion.findOne).mockResolvedValueOnce({
_id: 'version-1',
storageKey: 'storage-key'
} as any);
vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(existingInstance as any);
mocks.archiveSandboxResourceForRuntimeUpgrade.mockResolvedValueOnce({ success: true });
await expect(
createEditDebugSandbox({
skillId,
teamId: 'team-1',
tmbId: 'tmb-1',
image: { repository: 'new-runtime', tag: 'v2' },
archiveForUpgrade: true,
onProgress
})
).resolves.toMatchObject({
sandboxId: `edit-debug-${skillId}`,
status: { state: 'UpgradePrepared' }
});
expect(mocks.archiveSandboxResourceForRuntimeUpgrade).toHaveBeenCalledWith(existingInstance, {
ensureZipInSandbox: true
});
expect(onProgress).toHaveBeenNthCalledWith(1, {
sandboxId: `edit-debug-${skillId}`,
phase: 'runtimeUpgradeArchiving'
});
expect(onProgress).toHaveBeenNthCalledWith(2, {
sandboxId: `edit-debug-${skillId}`,
phase: 'runtimeUpgradeArchived'
});
expect(getSandboxClient).not.toHaveBeenCalled();
});
const setupRuntimeUpgradeArchiveFailure = () => {
const skillId = 'skill-1';
const existingInstance = {
_id: 'existing-instance',
provider: 'test-provider',
sandboxId: `edit-debug-${skillId}`,
status: 'running',
lastActiveAt: new Date('2026-01-01T00:00:00.000Z'),
metadata: {
image: { repository: 'old-runtime', tag: 'v1' },
versionId: 'version-1'
}
};
vi.mocked(MongoAgentSkills.findOne).mockResolvedValueOnce({
_id: skillId,
name: '测试的',
currentVersionId: 'version-1'
} as any);
vi.mocked(MongoAgentSkillsVersion.findOne).mockResolvedValueOnce({
_id: 'version-1',
storageKey: 'storage-key'
} as any);
vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(existingInstance as any);
mocks.archiveSandboxResourceForRuntimeUpgrade.mockResolvedValueOnce({
success: false,
error: 'Sandbox container does not exist physically'
});
return { skillId, existingInstance };
};
it('rejects runtime upgrade without deleting outdated sandbox when archive fails', async () => {
const { skillId, existingInstance } = setupRuntimeUpgradeArchiveFailure();
const onProgress = vi.fn();
await expect(
createEditDebugSandbox({
skillId,
teamId: 'team-1',
tmbId: 'tmb-1',
image: { repository: 'new-runtime', tag: 'v2' },
archiveForUpgrade: true,
onProgress
})
).rejects.toThrow(SandboxErrEnum.runtimeUpgradeFailed);
expect(mocks.archiveSandboxResourceForRuntimeUpgrade).toHaveBeenCalledWith(existingInstance, {
ensureZipInSandbox: true
});
expect(deleteSandboxResource).not.toHaveBeenCalledWith(existingInstance);
expect(downloadSkillPackage).not.toHaveBeenCalled();
expect(getSandboxClient).not.toHaveBeenCalled();
expect(deleteSandboxInstanceRecord).not.toHaveBeenCalledWith(existingInstance._id);
expect(onProgress).toHaveBeenNthCalledWith(1, {
sandboxId: `edit-debug-${skillId}`,
phase: 'runtimeUpgradeArchiving'
});
expect(onProgress).toHaveBeenNthCalledWith(2, {
sandboxId: `edit-debug-${skillId}`,
phase: 'failed',
message: SandboxErrEnum.runtimeUpgradeFailed
});
});
it('keeps runtime upgrade modal loading when refreshed during active archiving', async () => {
const skillId = 'skill-1';
const onProgress = vi.fn();
const archivingInstance = {
_id: 'archiving-instance',
provider: 'test-provider',
sandboxId: `edit-debug-${skillId}`,
status: 'stopped',
lastActiveAt: new Date(),
metadata: {
image: { repository: 'old-runtime', tag: 'v1' },
archive: {
state: 'archiving',
startedAt: new Date()
},
versionId: 'version-1'
}
};
vi.mocked(MongoAgentSkills.findOne).mockResolvedValueOnce({
_id: skillId,
name: '测试的',
currentVersionId: 'version-1'
} as any);
vi.mocked(MongoAgentSkillsVersion.findOne).mockResolvedValueOnce({
_id: 'version-1',
storageKey: 'storage-key'
} as any);
vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(archivingInstance as any);
vi.mocked(findSandboxResourcesBySourceChatTypeExcludeProvider).mockResolvedValueOnce([]);
await expect(
createEditDebugSandbox({
skillId,
teamId: 'team-1',
tmbId: 'tmb-1',
image: { repository: 'new-runtime', tag: 'v2' },
onProgress
})
).resolves.toMatchObject({
sandboxId: `edit-debug-${skillId}`,
status: { state: 'UpgradeInProgress' }
});
expect(onProgress).toHaveBeenCalledWith({
sandboxId: `edit-debug-${skillId}`,
phase: 'runtimeUpgradeArchiving'
});
expect(deleteStaleRuntimeUpgradeArchivingRecord).not.toHaveBeenCalled();
expect(deleteSandboxResource).not.toHaveBeenCalled();
expect(getSandboxClient).not.toHaveBeenCalled();
});
it('rejects timed-out runtime upgrade archiving state without cleanup or rebuild', async () => {
const skillId = 'skill-1';
const onProgress = vi.fn();
const archivingInstance = {
_id: 'archiving-instance',
provider: 'test-provider',
sandboxId: `edit-debug-${skillId}`,
status: 'stopped',
lastActiveAt: new Date('2026-01-01T00:00:00.000Z'),
metadata: {
image: { repository: 'old-runtime', tag: 'v1' },
archive: {
state: 'archiving',
startedAt: new Date('2026-01-01T00:00:00.000Z')
},
versionId: 'version-1'
}
};
vi.mocked(MongoAgentSkills.findOne).mockResolvedValueOnce({
_id: skillId,
name: '测试的',
currentVersionId: 'version-1'
} as any);
vi.mocked(MongoAgentSkillsVersion.findOne).mockResolvedValueOnce({
_id: 'version-1',
storageKey: 'storage-key'
} as any);
vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(archivingInstance as any);
await expect(
createEditDebugSandbox({
skillId,
teamId: 'team-1',
tmbId: 'tmb-1',
image: { repository: 'new-runtime', tag: 'v2' },
onProgress
})
).rejects.toThrow(SandboxErrEnum.runtimeUpgradeFailed);
expect(onProgress).toHaveBeenCalledWith({
sandboxId: `edit-debug-${skillId}`,
phase: 'runtimeUpgradeArchiving'
});
expect(onProgress).toHaveBeenCalledWith({
sandboxId: `edit-debug-${skillId}`,
phase: 'failed',
message: SandboxErrEnum.runtimeUpgradeFailed
});
expect(deleteStaleRuntimeUpgradeArchivingRecord).not.toHaveBeenCalled();
expect(deleteSandboxResource).not.toHaveBeenCalled();
expect(downloadSkillPackage).not.toHaveBeenCalled();
expect(getSandboxClient).not.toHaveBeenCalled();
expect(updateSandboxInstanceRecordBySandboxId).not.toHaveBeenCalled();
});
it('rejects edit-debug sandbox start without rebuilding from skill package when archived S3 package is missing', async () => {
const skillId = 'skill-1';
const packageBuffer = Buffer.from('zip');
const noSuchKeyError = Object.assign(new Error('The specified key does not exist.'), {
name: 'NoSuchKey',
code: 'NoSuchKey'
});
const provider = {
status: { state: 'Running' },
execute: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })),
writeFiles: vi.fn(async (entries: Array<{ path: string; data: Buffer }>) =>
entries.map((entry) => ({
path: entry.path,
bytesWritten: entry.data.length,
error: null
}))
)
};
const archivedInstance = {
_id: 'archived-current-provider-instance',
provider: 'test-provider',
......@@ -572,21 +935,7 @@ describe('createEditDebugSandbox', () => {
vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(archivedInstance as any);
vi.mocked(findSandboxResourcesBySourceChatTypeExcludeProvider).mockResolvedValueOnce([]);
vi.mocked(countRunningSandboxInstancesByType).mockResolvedValueOnce(0);
vi.mocked(getSandboxClient)
.mockRejectedValueOnce(noSuchKeyError)
.mockResolvedValueOnce({
provider,
delete: vi.fn()
} as any);
vi.mocked(downloadSkillPackage).mockResolvedValueOnce(packageBuffer);
vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({
image: 'test-image',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
status: { state: 'Running' }
} as any);
vi.mocked(updateSandboxInstanceRecordBySandboxId).mockResolvedValueOnce({
_id: 'rebuilt-doc'
} as any);
vi.mocked(getSandboxClient).mockRejectedValueOnce(noSuchKeyError);
await expect(
createEditDebugSandbox({
......@@ -594,24 +943,15 @@ describe('createEditDebugSandbox', () => {
teamId: 'team-1',
tmbId: 'tmb-1'
})
).resolves.toMatchObject({
sandboxId: `edit-debug-${skillId}`,
status: { state: 'Running' }
});
).rejects.toThrow(noSuchKeyError);
expect(getSandboxClient).toHaveBeenCalledTimes(2);
expect(deleteSandboxInstanceRecord).toHaveBeenCalledWith(archivedInstance._id);
expect(getSandboxClient).toHaveBeenCalledTimes(1);
expect(deleteSandboxInstanceRecord).not.toHaveBeenCalledWith(archivedInstance._id);
expect(deleteSandboxResource).not.toHaveBeenCalled();
expect(mocks.deleteWorkspaceArchive).not.toHaveBeenCalled();
expect(downloadSkillPackage).toHaveBeenCalledWith({
storageKey: 'storage-key'
});
expect(provider.writeFiles).toHaveBeenCalledWith([
{
path: '/workspace/skills/package.zip',
data: packageBuffer
}
]);
expect(downloadSkillPackage).not.toHaveBeenCalled();
expect(getReadySandboxInfo).not.toHaveBeenCalled();
expect(updateSandboxInstanceRecordBySandboxId).not.toHaveBeenCalled();
});
it('migrates archived stale-provider edit-debug records before restoring with the new provider', async () => {
......@@ -659,7 +999,7 @@ describe('createEditDebugSandbox', () => {
delete: vi.fn()
} as any);
vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({
image: 'test-image',
image: { repository: 'test-image' },
createdAt: new Date('2026-01-01T00:00:00.000Z'),
status: { state: 'Running' }
} as any);
......
......@@ -163,9 +163,13 @@
"sandbox_confirm_delete_title": "Confirm delete",
"sandbox_connect_failed_max_attempts": "Too many failed attempts to connect to the sandbox. Connection stopped.",
"sandbox_create_failed": "Create failed",
"sandbox_copy_absolute_path": "Copy absolute path",
"sandbox_copy_path_failed": "Failed to copy path",
"sandbox_copy_path_success": "Path copied",
"sandbox_delete": "Delete",
"sandbox_delete_failed": "Delete failed",
"sandbox_download": "Download",
"sandbox_download_all": "Download all",
"sandbox_download_failed": "Download failed",
"sandbox_entry_tooltip": "View Sandbox files",
"sandbox_file_already_exists": "A file or folder with the same name already exists in the target directory",
......@@ -189,6 +193,7 @@
"sandbox_save_failed": "Save failed",
"sandbox_search_files": "Search files",
"sandbox_select_file_edit": "Select a file to edit",
"sandbox_selected_items_count": "{{count}} selected items",
"sandbox_starting": "Starting sandbox (1/2)",
"sandbox_status_checkExisting": "Checking sandbox...",
"sandbox_status_connecting": "Connecting to sandbox...",
......@@ -202,8 +207,14 @@
"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_runtimeUpgradeArchived": "Virtual machine archived. Rebuilding runtime...",
"sandbox_status_runtimeUpgradeArchiving": "Archiving outdated virtual machine...",
"sandbox_status_runtimeUpgradeRequired": "Virtual machine runtime needs an upgrade",
"sandbox_status_uploadingPackage": "Uploading skill package to sandbox...",
"sandbox_terminal": "Terminal",
"sandbox_unzip": "Unzip",
"sandbox_unzip_failed": "Unzip failed",
"sandbox_unzip_success": "Unzipped",
"sandbox_upload_failed": "Upload failed",
"sandbox_upload_file": "Upload file",
"sandbox_upload_too_large": "File too large",
......
......@@ -184,6 +184,7 @@
"code_error.skill_error.un_auth_skill": "Unauthorized to Operate This Skill",
"code_error.sandbox_error.agent_sandbox_initializing": "The virtual machine is initializing. Please try again later.",
"code_error.sandbox_error.agent_sandbox_permission_denied": "The current app is not authorized to use the sandbox/VM. Please contact an administrator to configure it.",
"code_error.sandbox_error.runtime_upgrade_failed": "Virtual machine runtime upgrade failed. Please try again.",
"code_error.system_error.community_version_num_limit": "Exceeded Open Source Version Limit, Please Upgrade to Commercial Version: https://fastgpt.io",
"code_error.system_error.license_app_amount_limit": "Exceed the maximum number of applications in the system",
"code_error.system_error.license_dataset_amount_limit": "Exceed the maximum number of knowledge bases in the system",
......
......@@ -63,6 +63,12 @@
"sandbox_uploading": "Uploading skill package to sandbox...",
"sandbox_extracting": "Extracting skill package...",
"sandbox_lazy_init": "Initializing runtime environment...",
"sandbox_runtime_upgrade_archived": "Outdated virtual machine is ready. Rebuilding runtime...",
"sandbox_runtime_upgrade_archiving": "Archiving outdated virtual machine...",
"sandbox_runtime_upgrade_confirm": "Upgrade now",
"sandbox_runtime_upgrade_desc": "A newer virtual machine runtime is available. Upgrade before continuing. The upgrade will not affect workspace files, but dependencies need to be reinstalled.",
"sandbox_runtime_upgrade_failed": "Upgrade failed. Please try again.",
"sandbox_runtime_upgrade_required": "Virtual machine runtime upgrade notice",
"sandbox_ready": "Sandbox is ready",
"sandbox_ready_warm": "Sandbox is ready (warm start)",
"sandbox_failed": "Sandbox creation failed: {{message}}",
......
......@@ -163,9 +163,13 @@
"sandbox_confirm_delete_title": "确认删除",
"sandbox_connect_failed_max_attempts": "连接沙盒失败次数过多,已停止尝试。",
"sandbox_create_failed": "新建失败",
"sandbox_copy_absolute_path": "复制绝对路径",
"sandbox_copy_path_failed": "复制路径失败",
"sandbox_copy_path_success": "路径已复制",
"sandbox_delete": "删除",
"sandbox_delete_failed": "删除失败",
"sandbox_download": "下载",
"sandbox_download_all": "下载全部",
"sandbox_download_failed": "下载失败",
"sandbox_entry_tooltip": "查看虚拟机文件",
"sandbox_file_already_exists": "目标目录中已存在同名文件或文件夹",
......@@ -189,6 +193,7 @@
"sandbox_save_failed": "保存文件失败",
"sandbox_search_files": "搜索文件",
"sandbox_select_file_edit": "选择一个文件进行编辑",
"sandbox_selected_items_count": "已选 {{count}} 项",
"sandbox_starting": "沙盒启动中(1/2)",
"sandbox_status_checkExisting": "正在检查沙箱环境...",
"sandbox_status_connecting": "正在连接沙箱环境...",
......@@ -202,8 +207,14 @@
"sandbox_status_lazyInit": "虚拟机运行中...",
"sandbox_status_ready_cold": "沙箱环境就绪",
"sandbox_status_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_status_runtimeUpgradeArchived": "虚拟机归档完成,正在重建运行环境...",
"sandbox_status_runtimeUpgradeArchiving": "正在归档旧版虚拟机...",
"sandbox_status_runtimeUpgradeRequired": "虚拟机运行环境需要升级",
"sandbox_status_uploadingPackage": "正在上传技能包到沙箱...",
"sandbox_terminal": "终端",
"sandbox_unzip": "解压",
"sandbox_unzip_failed": "解压失败",
"sandbox_unzip_success": "解压完成",
"sandbox_upload_failed": "上传失败",
"sandbox_upload_file": "上传文件",
"sandbox_upload_too_large": "文件过大",
......
......@@ -184,6 +184,7 @@
"code_error.skill_error.un_auth_skill": "无权操作该技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虚拟机正在初始化,请稍后重试。",
"code_error.sandbox_error.agent_sandbox_permission_denied": "当前应用无权使用虚拟机,请联系管理员配置。",
"code_error.sandbox_error.runtime_upgrade_failed": "虚拟机运行环境升级失败,请重新尝试。",
"code_error.system_error.community_version_num_limit": "超出社区版数量限制,请升级商业版: https://fastgpt.in",
"code_error.system_error.license_app_amount_limit": "超出系统最大应用数量",
"code_error.system_error.license_dataset_amount_limit": "超出系统最大知识库数量",
......
......@@ -63,6 +63,12 @@
"sandbox_uploading": "正在上传 Skill 包到沙箱...",
"sandbox_extracting": "正在解压 Skill 包...",
"sandbox_lazy_init": "正在初始化运行环境...",
"sandbox_runtime_upgrade_archived": "旧版虚拟机已处理完成,正在重建运行环境...",
"sandbox_runtime_upgrade_archiving": "正在归档旧版虚拟机...",
"sandbox_runtime_upgrade_confirm": "立即升级",
"sandbox_runtime_upgrade_desc": "检测到当前虚拟机环境有新版,需要升级后才能正常使用。升级版本不会影响工作区的文件,但需要重新安装依赖包。",
"sandbox_runtime_upgrade_failed": "升级失败,请重新尝试",
"sandbox_runtime_upgrade_required": "虚拟机环境升级提示",
"sandbox_ready": "沙箱环境就绪",
"sandbox_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_failed": "沙箱创建失败: {{message}}",
......
......@@ -160,9 +160,13 @@
"sandbox_confirm_delete_title": "確認刪除",
"sandbox_connect_failed_max_attempts": "連接沙箱失敗次數過多,已停止嘗試。",
"sandbox_create_failed": "新建失敗",
"sandbox_copy_absolute_path": "複製絕對路徑",
"sandbox_copy_path_failed": "複製路徑失敗",
"sandbox_copy_path_success": "路徑已複製",
"sandbox_delete": "刪除",
"sandbox_delete_failed": "刪除失敗",
"sandbox_download": "下載",
"sandbox_download_all": "下載全部",
"sandbox_download_failed": "下載失敗",
"sandbox_entry_tooltip": "查看虛擬機器文件",
"sandbox_file_already_exists": "目標目錄中已存在同名文件或資料夾",
......@@ -186,6 +190,7 @@
"sandbox_save_failed": "儲存文件失敗",
"sandbox_search_files": "搜尋文件",
"sandbox_select_file_edit": "選擇一個文件進行編輯",
"sandbox_selected_items_count": "已選 {{count}} 項",
"sandbox_starting": "沙盒啟動中(1/2)",
"sandbox_status_checkExisting": "正在檢查沙箱環境...",
"sandbox_status_connecting": "正在連接沙箱環境...",
......@@ -199,8 +204,14 @@
"sandbox_status_lazyInit": "虛擬機運行中...",
"sandbox_status_ready_cold": "沙箱環境就緒",
"sandbox_status_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_status_runtimeUpgradeArchived": "虛擬機歸檔完成,正在重建運行環境...",
"sandbox_status_runtimeUpgradeArchiving": "正在歸檔舊版虛擬機...",
"sandbox_status_runtimeUpgradeRequired": "虛擬機運行環境需要升級",
"sandbox_status_uploadingPackage": "正在上傳技能包到沙箱...",
"sandbox_terminal": "終端機",
"sandbox_unzip": "解壓",
"sandbox_unzip_failed": "解壓失敗",
"sandbox_unzip_success": "解壓完成",
"sandbox_upload_failed": "上傳失敗",
"sandbox_upload_file": "上傳文件",
"sandbox_upload_too_large": "文件過大",
......
......@@ -182,6 +182,7 @@
"code_error.skill_error.un_auth_skill": "無權操作該技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虛擬機正在初始化,請稍後重試。",
"code_error.sandbox_error.agent_sandbox_permission_denied": "當前應用無權使用虛擬機,請聯絡管理員配置。",
"code_error.sandbox_error.runtime_upgrade_failed": "虛擬機運行環境升級失敗,請重新嘗試。",
"code_error.system_error.community_version_num_limit": "超出開源版數量限制,請升級商業版:https://fastgpt.io",
"code_error.system_error.license_app_amount_limit": "超出系統最大應用數量",
"code_error.system_error.license_dataset_amount_limit": "超出系統最大知識庫數量",
......
......@@ -63,6 +63,12 @@
"sandbox_uploading": "正在上傳 Skill 包到沙箱...",
"sandbox_extracting": "正在解壓 Skill 包...",
"sandbox_lazy_init": "正在初始化運行環境...",
"sandbox_runtime_upgrade_archived": "舊版虛擬機已處理完成,正在重建運行環境...",
"sandbox_runtime_upgrade_archiving": "正在歸檔舊版虛擬機...",
"sandbox_runtime_upgrade_confirm": "立即升級",
"sandbox_runtime_upgrade_desc": "檢測到目前虛擬機環境有新版,需要升級後才能正常使用。升級版本不會影響工作區的檔案,但需要重新安裝依賴包。",
"sandbox_runtime_upgrade_failed": "升級失敗,請重新嘗試",
"sandbox_runtime_upgrade_required": "虛擬機環境升級提示",
"sandbox_ready": "沙箱環境就緒",
"sandbox_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_failed": "沙箱創建失敗: {{message}}",
......
......@@ -4,9 +4,22 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { getDocPath } from '@/web/common/system/doc';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useTranslation } from 'next-i18next';
import type { ReactNode } from 'react';
import { useState } from 'react';
const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => {
type ProModalProps = {
isOpen?: boolean;
onClose?: () => void;
forceShow?: boolean;
title?: ReactNode;
content?: ReactNode;
primaryButtonText?: ReactNode;
primaryButtonLoading?: boolean;
onPrimaryClick?: () => void;
showSecondaryButton?: boolean;
};
const ProModal = (props: ProModalProps) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
......@@ -14,14 +27,26 @@ const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => {
const openModal = props?.isOpen ?? isOpen;
const onClose = props?.onClose ?? (() => setIsOpen(false));
const {
forceShow = false,
title = t('common:pro_modal_title'),
content,
primaryButtonText = t('common:pro_modal_unlock_button'),
primaryButtonLoading = false,
onPrimaryClick = () => {
window.open(getDocPath('/guide/version/commercial'), '_blank');
},
showSecondaryButton = true
} = props;
return feConfigs?.isPlus ? null : (
return feConfigs?.isPlus && !forceShow ? null : (
<MyModal
isOpen={openModal}
onClose={onClose}
showCloseButton={false}
w={'400px'}
minH={'392px'}
isCentered
>
<ModalBody
userSelect={'none'}
......@@ -52,50 +77,53 @@ const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => {
<MyIcon name={'star'} w={9} h={9} transform={'translateY(40%)'} />
</Flex>
<Box color={'myGray.900'} fontSize={'26px'} fontWeight={'bold'} lineHeight={'34px'}>
{t('common:pro_modal_title')}
{title}
</Box>
<VStack
w={'full'}
color={'myGray.900'}
fontSize={'18px'}
alignItems={'center'}
gap={0}
mt={7}
>
<Box lineHeight={'26px'}>{t('common:pro_modal_subtitle')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_1')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_2')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_3')}</Box>
<Box color={'myGray.500'} letterSpacing={'2px'} lineHeight={'26px'}>
......
</Box>
</VStack>
<Flex gap={3} flexDirection={'column'} w={'full'} mt={6}>
<Button
{content || (
<VStack
w={'full'}
h={'48px'}
borderRadius={'10px'}
onClick={() => {
window.open(getDocPath('/guide/version/commercial'), '_blank');
}}
fontSize={'16px'}
fontWeight={'medium'}
color={'myGray.900'}
fontSize={'18px'}
alignItems={'center'}
gap={0}
mt={7}
>
{t('common:pro_modal_unlock_button')}
</Button>
<Box lineHeight={'26px'}>{t('common:pro_modal_subtitle')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_1')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_2')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_3')}</Box>
<Box color={'myGray.500'} letterSpacing={'2px'} lineHeight={'26px'}>
......
</Box>
</VStack>
)}
<Flex gap={3} flexDirection={'column'} w={'full'} mt={6}>
<Button
w={'full'}
h={'48px'}
borderRadius={'10px'}
variant={'whiteBase'}
onClick={onPrimaryClick}
isLoading={primaryButtonLoading}
fontSize={'16px'}
fontWeight={'medium'}
borderColor={'#E4E7ED'}
boxShadow={'0 2px 5px rgba(15, 23, 42, 0.06)'}
onClick={onClose}
>
{t('common:pro_modal_later_button')}
{primaryButtonText}
</Button>
{showSecondaryButton && (
<Button
w={'full'}
h={'48px'}
borderRadius={'10px'}
variant={'whiteBase'}
fontSize={'16px'}
fontWeight={'medium'}
borderColor={'#E4E7ED'}
boxShadow={'0 2px 5px rgba(15, 23, 42, 0.06)'}
onClick={onClose}
>
{t('common:pro_modal_later_button')}
</Button>
)}
</Flex>
</VStack>
</ModalBody>
......
......@@ -228,6 +228,9 @@ export const useChatGenerate = ({
downloadingPackage: t('chat:sandbox_status_downloadingPackage'),
uploadingPackage: t('chat:sandbox_status_uploadingPackage'),
extractingPackage: t('chat:sandbox_status_extractingPackage'),
runtimeUpgradeRequired: t('chat:sandbox_status_runtimeUpgradeRequired'),
runtimeUpgradeArchiving: t('chat:sandbox_status_runtimeUpgradeArchiving'),
runtimeUpgradeArchived: t('chat:sandbox_status_runtimeUpgradeArchived'),
lazyInit: t('chat:sandbox_status_lazyInit')
};
......
......@@ -42,6 +42,7 @@ function MemberItemCard({
justifyContent="space-between"
alignItems="center"
key={key}
minW={0}
px="1"
py="1"
gap="2"
......@@ -63,13 +64,14 @@ function MemberItemCard({
p="1"
alignItems={'center'}
gap="2"
w="full"
flex="1 1 0"
minW={0}
>
{isChecked !== undefined && (
<Checkbox isDisabled={disabled} isChecked={isChecked} pointerEvents="none" />
)}
<Avatar src={avatar} w="1.5rem" borderRadius={'50%'} />
<Box flex={'1 0 0'} w={0}>
<Avatar src={avatar} w="1.5rem" flexShrink={0} borderRadius={'50%'} />
<Box flex={'1 1 0'} minW={0}>
<Box fontSize={'sm'} w={'100%'} noOfLines={1}>
{name === DefaultGroupName ? userInfo?.team.teamName : name}
</Box>
......@@ -79,31 +81,40 @@ function MemberItemCard({
</Box>
</Flex>
{showRoleSelect && (
<RoleSelect
disabled={disabled}
value={role}
Button={
<Flex
bg={'myGray.50'}
border="base"
fontSize={'sm'}
borderRadius={'md'}
minH={'18px'}
w="300px"
p="1"
alignItems={'end'}
justifyContent={'space-between'}
>
<RoleTags permission={role} />
<Flex h="18px" alignItems={'center'} justifyContent={'center'}>
<ChevronDownIcon fontSize="md" />
<Box flex="0 1 300px" minW="160px" maxW="300px">
<RoleSelect
disabled={disabled}
value={role}
Button={
<Flex
bg={'myGray.50'}
border="base"
fontSize={'sm'}
borderRadius={'md'}
minH={'18px'}
w="full"
p="1"
alignItems={'end'}
justifyContent={'space-between'}
overflow="hidden"
>
<RoleTags permission={role} />
<Flex h="18px" flexShrink={0} alignItems={'center'} justifyContent={'center'}>
<ChevronDownIcon fontSize="md" />
</Flex>
</Flex>
</Flex>
}
onChange={onRoleChange}
/>
}
onChange={onRoleChange}
width="100%"
/>
</Box>
)}
<Flex flexDirection={'row'} h={showRoleSelect ? '36px' : 'unset'} alignItems={'center'}>
<Flex
flexDirection={'row'}
h={showRoleSelect ? '36px' : 'unset'}
flexShrink={0}
alignItems={'center'}
>
{onDelete !== undefined && !disabled ? (
<MyIcon
name="common/closeLight"
......
......@@ -63,7 +63,7 @@ function RoleSelect({
const roleOptions = useMemo(() => {
if (!permissionList) return { singleOptions: [], checkboxList: [] };
const list = Object.entries(permissionList).map(([_, value]) => {
const list = Object.values(permissionList).map((value) => {
return {
name: value.name,
value: value.value,
......@@ -104,6 +104,8 @@ function RoleSelect({
.map((item) => item.value);
}, [role, roleOptions.checkboxList]);
const menuMinWidth = typeof width === 'number' ? `${width}px !important` : width;
const onSelectRole = (newRole: RoleValueType) => {
if (newRole === role) return;
onChange(newRole);
......@@ -121,7 +123,7 @@ function RoleSelect({
<Menu offset={offset} isOpen={isOpen} autoSelect={false} direction={'ltr'}>
<Box
ref={ref}
w="fit-content"
w={width}
onMouseEnter={() => {
if (disabled) return;
if (trigger === 'hover') {
......@@ -139,6 +141,7 @@ function RoleSelect({
>
<MenuButton
position={'relative'}
w="full"
cursor={disabled ? 'not-allowed' : 'pointer'}
onClickCapture={() => {
if (trigger === 'click') {
......@@ -150,7 +153,7 @@ function RoleSelect({
{Button}
</MenuButton>
<MenuList
minW={isOpen ? `${width}px !important` : 0}
minW={isOpen ? menuMinWidth : 0}
p="3"
border={'1px solid #fff'}
boxShadow={
......
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Center, VStack, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { Box, Flex } from '@chakra-ui/react';
import { SkillDetailContext } from '../../dashboard/skill/detail/context';
import { useContextSelector } from 'use-context-selector';
import MyBox from '@fastgpt/web/components/common/MyBox';
import type Editor from '@monaco-editor/react';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import FileTree from './components/FileTree';
......@@ -27,9 +24,13 @@ export type Props = {
outLinkAuthData?: OutLinkChatAuthProps;
showFileOps?: boolean;
showDownload?: boolean;
showFileTreeDownload?: boolean;
defaultViewMode?: 'source' | 'preview';
isPreparing?: boolean;
showTerminal?: boolean;
enablePathCopy?: boolean;
enableZipExtract?: boolean;
enableMultiSelect?: boolean;
onError?: (err: Error) => void;
headerRight?: React.ReactNode;
bg?: string;
......@@ -42,14 +43,17 @@ const SandboxEditor = ({
outLinkAuthData,
showFileOps = true,
showDownload = true,
showFileTreeDownload,
defaultViewMode,
isPreparing = false,
showTerminal = false,
enablePathCopy = false,
enableZipExtract = false,
enableMultiSelect = false,
onError,
headerRight,
bg
}: Props) => {
const { t } = useTranslation();
const saveAllRef = useContextSelector(SkillDetailContext, (v) => v.saveAllRef);
const editorRef = useRef<SandboxEditorInstance>();
const editorLayoutRef = useRef<HTMLDivElement>(null);
......@@ -59,6 +63,7 @@ const SandboxEditor = ({
() => resolveSandboxTarget({ appId, chatTarget }),
[appId, chatTarget?.appId, chatTarget?.skillId]
);
const fileTreeShowDownload = showFileTreeDownload ?? showDownload;
const {
fileTree,
......@@ -78,6 +83,7 @@ const SandboxEditor = ({
searchQuery,
setSearchQuery,
activeFile,
refreshWorkspace,
openFile,
closeFile,
saveFile,
......@@ -85,9 +91,10 @@ const SandboxEditor = ({
downloadCurrentFile,
onCreateNode,
onRenameComplete,
onMoveFile,
onMoveFiles,
onDeleteFile,
onUploadFiles,
onExecCommand,
toggleDirectory
} = useSandboxFileStore({
sandboxTarget,
......@@ -185,14 +192,20 @@ const SandboxEditor = ({
toggleDirectory={toggleDirectory}
onCreateNode={onCreateNode}
onRenameComplete={onRenameComplete}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDeleteFile={onDeleteFile}
onUploadFiles={onUploadFiles}
onExecCommand={onExecCommand}
onRefreshWorkspace={refreshWorkspace}
setExpandedDirs={setExpandedDirs}
sandboxTarget={sandboxTarget}
chatId={chatId}
outLinkAuthData={outLinkAuthData}
showFileOps={showFileOps}
showDownload={fileTreeShowDownload}
enablePathCopy={enablePathCopy}
enableZipExtract={enableZipExtract}
enableMultiSelect={enableMultiSelect}
isLoading={isInitialLoading}
/>
);
......
......@@ -15,7 +15,9 @@ import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { Trans, useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { i18nT } from '@fastgpt/global/common/i18n/utils';
import type { ExecuteResult } from '@fastgpt-sdk/sandbox-adapter';
import {
DndContext,
useSensor,
......@@ -28,7 +30,17 @@ import {
} from '@dnd-kit/core';
import type { DragEndEvent, DragStartEvent, CollisionDetection } from '@dnd-kit/core';
import FileTreeNode, { InlineCreateNode } from './FileTreeNode';
import { getIconByFilename } from '../utils';
import {
buildSandboxMoveOperations,
findNodeByPath,
getIconByFilename,
getSandboxParentPath,
getSafeSandboxCommandPath,
getSafeSandboxPathSegment,
getTargetDirectoryPath,
getTopLevelSandboxPaths,
type SandboxMoveOperation
} from '../utils';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import type { ChatTargetInputType } from '@fastgpt/global/openapi/core/chat/api';
......@@ -86,14 +98,23 @@ type Props = {
toggleDirectory: (node: TreeNode) => Promise<void> | void;
onCreateNode: (parentPath: string, name: string, type: 'file' | 'directory') => Promise<void>;
onRenameComplete: (oldPath: string, newName: string) => Promise<void>;
onMoveFile: (srcPath: string, targetDirPath: string) => Promise<void>;
onMoveFiles: (
operations: SandboxMoveOperation[],
options?: { expandPath?: string | null }
) => Promise<SandboxMoveOperation[]>;
onDeleteFile: (path: string) => Promise<void>;
onUploadFiles: (files: FileList, targetDirPath: string) => Promise<void>;
onExecCommand: (command: string, timeoutMs?: number) => Promise<ExecuteResult>;
onRefreshWorkspace: (options?: { preserveExpandedDirs?: boolean }) => Promise<void>;
setExpandedDirs: React.Dispatch<React.SetStateAction<Set<string>>>;
sandboxTarget: ChatTargetInputType;
chatId: string;
outLinkAuthData?: OutLinkChatAuthProps;
showFileOps?: boolean;
showDownload?: boolean;
enablePathCopy?: boolean;
enableZipExtract?: boolean;
enableMultiSelect?: boolean;
isLoading?: boolean;
};
......@@ -112,6 +133,47 @@ const FileTreeSkeleton = () => {
);
};
const buildResolveAbsolutePathsCommand = (paths: string[]) => {
const quotedPaths = paths.map((path) => shellQuote(getSafeSandboxCommandPath(path))).join(' ');
return [
'workspace_root=$(pwd -P)',
`for path in ${quotedPaths}; do`,
' if [ "$path" = "." ]; then',
' printf \'%s\\n\' "$workspace_root"',
' else',
' printf \'%s/%s\\n\' "$workspace_root" "${path#./}"',
' fi',
'done'
].join('\n');
};
const getIsZipFile = (node: TreeNode) => node.type === 'file' && /\.zip$/i.test(node.name);
const stripZipExtension = (name: string) => name.replace(/\.zip$/i, '');
const buildExtractZipToNamedDirCommand = (params: {
zipPath: string;
parentPath: string;
targetDirName: string;
}) => {
const { zipPath, parentPath, targetDirName } = params;
return [
`zip_path=${shellQuote(getSafeSandboxCommandPath(zipPath))}`,
`parent_dir=${shellQuote(getSafeSandboxCommandPath(parentPath))}`,
`base_name=${shellQuote(getSafeSandboxPathSegment(targetDirName))}`,
'target_dir="$parent_dir/$base_name"',
'index=1',
'while [ -e "$target_dir" ]; do',
' target_dir="$parent_dir/$base_name-$index"',
' index=$((index + 1))',
'done',
'mkdir -p "$target_dir"',
'unzip -q "$zip_path" -d "$target_dir"',
'printf "%s\\n" "$target_dir"'
].join('\n');
};
const DroppableRootBox = ({
children,
activeNode,
......@@ -167,14 +229,20 @@ const FileTree = ({
toggleDirectory,
onCreateNode,
onRenameComplete,
onMoveFile,
onMoveFiles,
onDeleteFile,
onUploadFiles,
onExecCommand,
onRefreshWorkspace,
setExpandedDirs,
sandboxTarget,
chatId,
outLinkAuthData,
showFileOps = true,
showDownload = true,
enablePathCopy = false,
enableZipExtract = false,
enableMultiSelect = false,
isLoading = false
}: Props) => {
const { t } = useTranslation(['chat', 'common']);
......@@ -190,22 +258,45 @@ const FileTree = ({
const [activeNode, setActiveNode] = useState<TreeNode | null>(null);
const [activeOverPath, setActiveOverPath] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const effectiveSelectedPaths =
enableMultiSelect && selectedPath && !selectedPaths.has(selectedPath)
? new Set([selectedPath])
: selectedPaths;
const findNodeByPath = (nodes: TreeNode[], targetPath: string): TreeNode | null => {
for (const node of nodes) {
if (node.path === targetPath) return node;
if (node.children) {
const res = findNodeByPath(node.children, targetPath);
if (res) return res;
}
const getOperationSelectedPaths = (basePath: string) => {
if (enableMultiSelect && effectiveSelectedPaths.has(basePath)) {
return getTopLevelSandboxPaths(Array.from(effectiveSelectedPaths));
}
return null;
return basePath === '.' ? [] : [basePath];
};
const getParentPath = (path: string) => {
const parts = path.split('/');
parts.pop();
return parts.join('/') || '.';
const selectSingleNode = (path: string) => {
setSelectedPath(path);
setSelectedPaths(new Set([path]));
};
const toggleSelectedNode = (path: string) => {
if (!enableMultiSelect) {
selectSingleNode(path);
return;
}
const next = new Set(effectiveSelectedPaths);
if (next.has(path)) {
next.delete(path);
if (next.size === 0) {
next.add(path);
setSelectedPath(path);
} else {
const remainingPaths = Array.from(next);
setSelectedPath(remainingPaths[remainingPaths.length - 1] || '');
}
} else {
next.add(path);
setSelectedPath(path);
}
setSelectedPaths(next);
};
const getRealOverDestPath = () => {
......@@ -213,7 +304,7 @@ const FileTree = ({
if (activeOverPath === '.') return '.';
const node = findNodeByPath(filteredTree, activeOverPath);
if (node && node.type === 'file') {
return getParentPath(activeOverPath);
return getSandboxParentPath(activeOverPath);
}
return activeOverPath;
};
......@@ -222,6 +313,11 @@ const FileTree = ({
const handleDragStart = (event: DragStartEvent) => {
const node = event.active.data.current?.node as TreeNode | undefined;
if (node) {
if (!enableMultiSelect || !effectiveSelectedPaths.has(node.path)) {
selectSingleNode(node.path);
} else {
setSelectedPath(node.path);
}
setActiveNode(node);
setActiveOverPath(null);
}
......@@ -299,11 +395,18 @@ const FileTree = ({
if (overId !== '.') {
const overNode = findNodeByPath(filteredTree, overId);
if (overNode && overNode.type === 'file') {
destDirPath = getParentPath(overId);
destDirPath = getSandboxParentPath(overId);
}
}
if (destDirPath === srcPath || destDirPath.startsWith(srcPath + '/')) {
const sourcePaths = getOperationSelectedPaths(srcPath);
if (sourcePaths.length === 0) return;
if (
sourcePaths.some(
(sourcePath) => destDirPath === sourcePath || destDirPath.startsWith(sourcePath + '/')
)
) {
toast({
title: t('chat:sandbox_move_failed'),
description: t('chat:sandbox_move_into_self_forbidden'),
......@@ -312,19 +415,20 @@ const FileTree = ({
return;
}
const lastSlash = srcPath.lastIndexOf('/');
const currentParentPath = lastSlash === -1 ? '.' : srcPath.substring(0, lastSlash);
if (currentParentPath === destDirPath) {
return;
}
const movePlan = buildSandboxMoveOperations(sourcePaths, destDirPath);
const moveItems = movePlan;
// 计算移动后的新绝对路径,并在前端同步更新展开的文件夹列表状态,防止移动后因路径失效导致文件夹自动折叠
const srcName = srcPath.substring(lastSlash + 1);
const newPath = destDirPath === '.' ? srcName : `${destDirPath}/${srcName}`;
if (moveItems.length === 0) return;
// 校验重名冲突:目标路径是否已经存在同名节点
const conflictNode = findNodeByPath(filteredTree, newPath);
if (conflictNode) {
const newPathSet = new Set<string>();
const hasConflict =
moveItems.some((item) => {
if (newPathSet.has(item.newPath)) return true;
newPathSet.add(item.newPath);
return false;
}) || moveItems.some((item) => findNodeByPath(filteredTree, item.newPath));
if (hasConflict) {
toast({
title: t('chat:sandbox_move_failed'),
description: t('chat:sandbox_file_already_exists'),
......@@ -336,59 +440,38 @@ const FileTree = ({
const destNode = findNodeByPath(filteredTree, destDirPath);
const shouldExpandDest = destDirPath !== '.' && destNode && destNode.loaded;
setExpandedDirs((prev) => {
const next = new Set<string>();
prev.forEach((path) => {
if (path === srcPath) {
next.add(newPath);
} else if (path.startsWith(srcPath + '/')) {
next.add(newPath + path.substring(srcPath.length));
} else {
next.add(path);
}
try {
const movedItems = await onMoveFiles(moveItems, {
expandPath: shouldExpandDest ? destDirPath : null
});
if (shouldExpandDest) {
next.add(destDirPath);
}
return next;
});
try {
await onMoveFile(srcPath, destDirPath);
const movedPathMap = new Map(movedItems.map((item) => [item.sourcePath, item.newPath]));
const activeMovedPath = movedPathMap.get(srcPath);
const nextSelectedPath =
activeMovedPath || movePlan.find((item) => item.sourcePath === srcPath)?.sourcePath || '';
setSelectedPaths(
new Set(movePlan.map((item) => movedPathMap.get(item.sourcePath) || item.sourcePath))
);
setSelectedPath(nextSelectedPath);
} catch (error) {
toast({
title: t('chat:sandbox_move_failed'),
description: getErrText(error),
status: 'error'
});
const movedItems =
error instanceof Error && 'movedItems' in error
? ((error as Error & { movedItems?: typeof moveItems }).movedItems ?? [])
: [];
if (movedItems.length > 0) {
const movedPathMap = new Map(movedItems.map((item) => [item.sourcePath, item.newPath]));
setSelectedPaths(
new Set(movePlan.map((item) => movedPathMap.get(item.sourcePath) || item.sourcePath))
);
const nextSelectedPath = movedPathMap.get(srcPath) || srcPath;
setSelectedPath(nextSelectedPath);
}
}
};
// 根据当前选中态,自动计算目标父文件夹路径
const getTargetDirPathFromSelected = () => {
let parentPath = '.';
if (selectedPath) {
if (selectedPath === '.') {
parentPath = '.';
} else {
const selectedNode = findNodeByPath(filteredTree, selectedPath);
if (selectedNode) {
if (selectedNode.type === 'directory') {
parentPath = selectedPath;
} else {
parentPath = getParentPath(selectedPath);
}
} else {
const isFile = selectedPath.includes('.') && !selectedPath.startsWith('.');
if (isFile) {
parentPath = getParentPath(selectedPath);
} else {
parentPath = selectedPath;
}
}
}
}
return parentPath;
return getTargetDirectoryPath(filteredTree, selectedPath);
};
// 上端控制区触发
......@@ -441,13 +524,25 @@ const FileTree = ({
setExpandedDirs(new Set());
};
const getContextSelectedPaths = () => {
if (!contextMenu) return [];
if (enableMultiSelect && effectiveSelectedPaths.has(contextMenu.node.path)) {
return Array.from(effectiveSelectedPaths);
}
return [contextMenu.node.path];
};
// 右键菜单动作分发
const handleContextMenu = (e: React.MouseEvent, node: TreeNode) => {
e.preventDefault();
if (node.path === '.') {
if (!showFileOps) return;
}
setSelectedPath(node.path);
if (enableMultiSelect && effectiveSelectedPaths.has(node.path)) {
setSelectedPath(node.path);
} else {
selectSingleNode(node.path);
}
setContextMenu({
x: e.clientX,
y: e.clientY,
......@@ -458,6 +553,7 @@ const FileTree = ({
const handleBlankContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
if (!showFileOps) return;
selectSingleNode('.');
setContextMenu({
x: e.clientX,
y: e.clientY,
......@@ -473,7 +569,7 @@ const FileTree = ({
const handleCtxCreateFile = async () => {
if (!contextMenu) return;
const node = contextMenu.node;
const parentPath = node.type === 'directory' ? node.path : getParentPath(node.path);
const parentPath = node.type === 'directory' ? node.path : getSandboxParentPath(node.path);
if (parentPath !== '.') {
const parentNode = findNodeByPath(filteredTree, parentPath);
......@@ -494,7 +590,7 @@ const FileTree = ({
const handleCtxCreateDir = async () => {
if (!contextMenu) return;
const node = contextMenu.node;
const parentPath = node.type === 'directory' ? node.path : getParentPath(node.path);
const parentPath = node.type === 'directory' ? node.path : getSandboxParentPath(node.path);
if (parentPath !== '.') {
const parentNode = findNodeByPath(filteredTree, parentPath);
......@@ -521,13 +617,20 @@ const FileTree = ({
const handleCtxDelete = () => {
if (!contextMenu) return;
const node = contextMenu.node;
const deletePaths = getOperationSelectedPaths(node.path);
if (deletePaths.length === 0) return;
const deleteName =
deletePaths.length > 1
? t('chat:sandbox_selected_items_count', { count: deletePaths.length })
: node.name;
setContextMenu(null);
openConfirm({
title: t('chat:sandbox_confirm_delete_title'),
customContent: (
<Trans
i18nKey={i18nT('chat:sandbox_confirm_delete_content')}
values={{ name: node.name }}
values={{ name: deleteName }}
components={{
name: <Text as="span" fontWeight="600" color="red.600" />
}}
......@@ -536,13 +639,19 @@ const FileTree = ({
confirmButtonVariant: 'dangerFill',
onConfirm: async () => {
try {
await onDeleteFile(node.path);
for (const path of deletePaths) {
await onDeleteFile(path);
setSelectedPaths(
(prev) =>
new Set(
Array.from(prev).filter(
(selectedPath) => selectedPath !== path && !selectedPath.startsWith(path + '/')
)
)
);
}
setSelectedPaths(new Set());
} catch (error) {
toast({
title: t('chat:sandbox_delete_failed'),
description: getErrText(error),
status: 'error'
});
throw error;
}
}
......@@ -551,18 +660,94 @@ const FileTree = ({
const handleCtxDownload = async () => {
if (!contextMenu) return;
const paths = getContextSelectedPaths();
setContextMenu(null);
try {
for (const path of paths) {
await downloadSandbox({
...sandboxTarget,
chatId,
outLinkAuthData,
path
});
}
} catch (error) {
toast({
title: t('chat:sandbox_download_failed'),
description: getErrText(error),
status: 'error'
});
}
};
const handleCtxCopyAbsolutePath = async () => {
if (!contextMenu) return;
const selectedPaths = getContextSelectedPaths();
setContextMenu(null);
try {
const result = await onExecCommand(buildResolveAbsolutePathsCommand(selectedPaths), 5000);
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.stdout || t('chat:sandbox_copy_path_failed'));
}
const stdout = result.stdout.replace(/\r?\n$/, '');
const absolutePaths = stdout ? stdout.split(/\r?\n/) : [];
if (absolutePaths.length !== selectedPaths.length) {
throw new Error(t('chat:sandbox_copy_path_failed'));
}
await navigator.clipboard.writeText(absolutePaths.join('\n'));
toast({
title: t('chat:sandbox_copy_path_success'),
status: 'success'
});
} catch (error) {
toast({
title: t('chat:sandbox_copy_path_failed'),
description: getErrText(error),
status: 'error'
});
}
};
const handleCtxExtractZip = async () => {
if (!contextMenu || !getIsZipFile(contextMenu.node)) return;
const path = contextMenu.node.path;
setContextMenu(null);
const parentPath = getSandboxParentPath(path);
try {
await downloadSandbox({
...sandboxTarget,
chatId,
outLinkAuthData,
path: path
const result = await onExecCommand(
buildExtractZipToNamedDirCommand({
zipPath: path,
parentPath,
targetDirName: stripZipExtension(contextMenu.node.name)
}),
30000
);
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.stdout || t('chat:sandbox_unzip_failed'));
}
setExpandedDirs((prev) => {
const next = new Set(prev);
if (parentPath !== '.') {
next.add(parentPath);
}
return next;
});
await onRefreshWorkspace({ preserveExpandedDirs: true });
toast({
title: t('chat:sandbox_unzip_success'),
status: 'success'
});
} catch (error) {
toast({
title: t('chat:sandbox_download_failed'),
title: t('chat:sandbox_unzip_failed'),
description: getErrText(error),
status: 'error'
});
......@@ -578,7 +763,9 @@ const FileTree = ({
loadingDirs={loadingDirs}
activeFilePath={activeFilePath}
selectedPath={selectedPath}
setSelectedPath={setSelectedPath}
selectedPaths={effectiveSelectedPaths}
selectSingleNode={selectSingleNode}
toggleSelectedNode={toggleSelectedNode}
realOverDestPath={realOverDestPath}
openFile={openFile}
toggleDirectory={toggleDirectory}
......@@ -591,10 +778,15 @@ const FileTree = ({
onConfirmCreate={handleConfirmCreate}
onCancelCreate={() => setCreatingNode(null)}
showFileOps={showFileOps}
enableMultiSelect={enableMultiSelect}
/>
));
};
const contextSelectedPaths = getContextSelectedPaths();
const isMultiContextMenu = contextSelectedPaths.length > 1;
const showContextSingleNodeOps = !isMultiContextMenu;
return (
<Box
flex="1"
......@@ -856,16 +1048,38 @@ const FileTree = ({
<ContextMenuItem label={t('chat:sandbox_new_folder')} onClick={handleCtxCreateDir} />
</>
)}
{contextMenu.node.path === '.' && showDownload && (
<>
{showFileOps && <Box borderBottom="1px solid" borderColor="myGray.100" my={1} />}
<ContextMenuItem label={t('chat:sandbox_download_all')} onClick={handleCtxDownload} />
</>
)}
{contextMenu.node.path !== '.' && (
<>
{showFileOps && <Box borderBottom="1px solid" borderColor="myGray.100" my={1} />}
{showFileOps && (
{showContextSingleNodeOps && showFileOps && (
<ContextMenuItem label={t('chat:sandbox_rename')} onClick={handleCtxRename} />
)}
<ContextMenuItem label={t('chat:sandbox_download')} onClick={handleCtxDownload} />
{enablePathCopy && (
<ContextMenuItem
label={t('chat:sandbox_copy_absolute_path')}
onClick={handleCtxCopyAbsolutePath}
/>
)}
{showContextSingleNodeOps &&
showFileOps &&
enableZipExtract &&
getIsZipFile(contextMenu.node) && (
<ContextMenuItem label={t('chat:sandbox_unzip')} onClick={handleCtxExtractZip} />
)}
{showDownload && (
<ContextMenuItem label={t('chat:sandbox_download')} onClick={handleCtxDownload} />
)}
{showFileOps && (
<>
<Box borderBottom="1px solid" borderColor="myGray.100" my={1} />
{(showContextSingleNodeOps || enablePathCopy || showDownload) && (
<Box borderBottom="1px solid" borderColor="myGray.100" my={1} />
)}
<ContextMenuItem
label={t('chat:sandbox_delete')}
onClick={handleCtxDelete}
......
......@@ -12,7 +12,9 @@ type Props = {
loadingDirs: Set<string>;
activeFilePath: string;
selectedPath: string;
setSelectedPath: (path: string) => void;
selectedPaths: Set<string>;
selectSingleNode: (path: string) => void;
toggleSelectedNode: (path: string) => void;
realOverDestPath: string | null;
openFile: (path: string) => void;
toggleDirectory: (node: TreeNode) => void;
......@@ -25,6 +27,7 @@ type Props = {
onConfirmCreate: (name: string) => void;
onCancelCreate: () => void;
showFileOps?: boolean;
enableMultiSelect?: boolean;
};
const FileTreeNode = ({
......@@ -33,7 +36,9 @@ const FileTreeNode = ({
loadingDirs,
activeFilePath,
selectedPath,
setSelectedPath,
selectedPaths,
selectSingleNode,
toggleSelectedNode,
realOverDestPath,
openFile,
toggleDirectory,
......@@ -45,12 +50,14 @@ const FileTreeNode = ({
renderTreeNodes,
onConfirmCreate,
onCancelCreate,
showFileOps = true
showFileOps = true,
enableMultiSelect = false
}: Props) => {
const isExpanded = expandedDirs.has(node.path);
const isLoading = loadingDirs.has(node.path);
const isActive = node.type === 'file' && activeFilePath === node.path;
const isSelected = selectedPath === node.path;
const shouldShowActiveBg = isActive && !enableMultiSelect;
const isSelected = enableMultiSelect ? selectedPaths.has(node.path) : selectedPath === node.path;
const isRenaming = renamingPath === node.path;
const shouldShowArrow = node.type === 'directory';
......@@ -75,6 +82,14 @@ const FileTreeNode = ({
// 完美VSCode拖放体验:只允许文件夹节点和根目录Box显示放置高亮
const isOverNode = node.type === 'directory' && (isOver || realOverDestPath === node.path);
const nodeBg = isOverNode
? 'rgba(56, 139, 253, 0.08)'
: isSelected
? 'primary.100'
: shouldShowActiveBg
? 'myGray.05'
: 'transparent';
const hoverBg = isOverNode || isSelected ? nodeBg : 'myGray.05';
return (
<Box>
......@@ -88,22 +103,20 @@ const FileTreeNode = ({
h="28px"
cursor="pointer"
opacity={isDragging ? 0.4 : 1}
_hover={{ bg: 'myGray.05' }}
bg={
isOverNode
? 'rgba(56, 139, 253, 0.08)'
: isSelected
? 'primary.100'
: isActive
? 'myGray.05'
: 'transparent'
}
_hover={{ bg: hoverBg }}
bg={nodeBg}
border={isOverNode ? '1px dashed #2B5FD9' : '1px solid transparent'}
borderRadius="xs"
onClick={() => {
onClick={(e) => {
if (!node || !node.path) return;
if (enableMultiSelect && (e.ctrlKey || e.metaKey)) {
toggleSelectedNode(node.path);
return;
}
selectSingleNode(node.path);
if (node.type === 'file') {
setSelectedPath(node.path);
openFile(node.path);
} else {
toggleDirectory(node);
......
......@@ -18,6 +18,7 @@ import type { TreeNode } from './components/FileTree';
import type { OpenedFile } from './components/FileTabs';
import type { ChatTargetInputType } from '@fastgpt/global/openapi/core/chat/api';
import { getSandboxTargetId, resolveSandboxTarget } from './types';
import type { ExecuteResult } from '@fastgpt-sdk/sandbox-adapter';
import {
getLanguageByFileName,
getIsBinaryByLanguage,
......@@ -27,7 +28,13 @@ import {
renameTreeNodeInTree,
findNodeByPath,
sortTreeNodes,
updateTreeNode
updateTreeNode,
replacePathPrefix,
getSandboxPathName,
getSandboxParentPath,
joinSandboxPath,
applySandboxMoveOperationsToExpandedDirs,
type SandboxMoveOperation
} from './utils';
const SYSTEM_FILE_NAMES = ['.DS_Store'];
......@@ -73,12 +80,6 @@ const encodeBase64 = (content: string) => {
const isValidPathSegment = (name: string) =>
!!name && name !== '.' && name !== '..' && !INVALID_PATH_SEGMENT_CHARS.test(name);
const replacePathPrefix = (path: string, oldPath: string, newPath: string) => {
if (path === oldPath) return newPath;
if (path.startsWith(oldPath + '/')) return `${newPath}${path.slice(oldPath.length)}`;
return path;
};
/**
* useSandboxEditor —— UI Hook
*
......@@ -408,7 +409,11 @@ export const useSandboxFileStore = ({
// RPC 调用
const rpcCall = useCallback(
async <T = unknown,>(method: string, params: unknown): Promise<T> => {
async <T = unknown,>(
method: string,
params: unknown,
options: { timeoutMs?: number } = {}
): Promise<T> => {
if (stoppedConnectErrorRef.current) {
throw stoppedConnectErrorRef.current;
}
......@@ -426,7 +431,7 @@ export const useSandboxFileStore = ({
const timer = window.setTimeout(() => {
pendingRpcRequestsRef.current.delete(id);
reject(new Error(`Sandbox RPC timeout: ${method}`));
}, RPC_TIMEOUT_MS);
}, options.timeoutMs ?? RPC_TIMEOUT_MS);
pendingRpcRequestsRef.current.set(id, {
resolve: (res) => {
......@@ -1228,10 +1233,8 @@ export const useSandboxFileStore = ({
}
const oldName = oldPath.split('/').pop() || '';
const parts = oldPath.split('/');
parts.pop();
const parentPath = parts.join('/');
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
const parentPath = getSandboxParentPath(oldPath);
const newPath = joinSandboxPath(parentPath, newName);
if (oldPath === newPath) return;
......@@ -1271,25 +1274,34 @@ export const useSandboxFileStore = ({
);
// 移动文件/目录(拖拽移动) (乐观更新)
const onMoveFile = useCallback(
async (srcPath: string, targetDirPath: string) => {
const parts = srcPath.split('/');
const fileName = parts.pop() || '';
const srcParentPath = parts.join('/') || '.';
const destPath = targetDirPath === '.' ? fileName : `${targetDirPath}/${fileName}`;
if (srcPath === destPath) return;
const onMoveFiles = useCallback(
async (operations: SandboxMoveOperation[], options?: { expandPath?: string | null }) => {
if (operations.length === 0) return [];
// 1. 乐观更新
updateStatePaths(srcPath, destPath);
setFileTree((prevTree) => moveTreeNodeInTree(prevTree, srcPath, targetDirPath));
operations.forEach((item) => {
updateStatePaths(item.sourcePath, item.newPath);
});
setExpandedDirs((prev) =>
applySandboxMoveOperationsToExpandedDirs(prev, operations, options?.expandPath)
);
setFileTree((prevTree) =>
operations.reduce(
(tree, item) => moveTreeNodeInTree(tree, item.sourcePath, item.targetDirPath),
prevTree
)
);
// 2. 异步请求,失败时回滚
const movedItems: SandboxMoveOperation[] = [];
try {
await rpcCall('fs/move', {
from: srcPath,
to: destPath
});
for (const item of operations) {
await rpcCall('fs/move', {
from: item.sourcePath,
to: item.newPath
});
movedItems.push(item);
}
return movedItems;
} catch (error) {
console.error('Failed to move file:', error);
toast({
......@@ -1297,14 +1309,64 @@ export const useSandboxFileStore = ({
description: getErrText(error),
status: 'error'
});
updateStatePaths(destPath, srcPath);
setFileTree((prevTree) => moveTreeNodeInTree(prevTree, destPath, srcParentPath));
const movedPathSet = new Set(movedItems.map((item) => item.newPath));
const rollbackOperations = operations
.filter((item) => !movedPathSet.has(item.newPath))
.map((item) => ({
sourcePath: item.newPath,
currentParentPath: item.targetDirPath,
targetDirPath: item.currentParentPath,
newPath: item.sourcePath
}));
operations
.slice()
.reverse()
.forEach((item) => {
if (!movedPathSet.has(item.newPath)) {
updateStatePaths(item.newPath, item.sourcePath);
}
});
setExpandedDirs((prev) =>
applySandboxMoveOperationsToExpandedDirs(prev, rollbackOperations.reverse())
);
setFileTree((prevTree) =>
operations
.slice()
.reverse()
.reduce((tree, item) => {
return movedPathSet.has(item.newPath)
? tree
: moveTreeNodeInTree(tree, item.newPath, item.currentParentPath);
}, prevTree)
);
await refreshWorkspace({ preserveExpandedDirs: true });
throw Object.assign(error instanceof Error ? error : new Error(getErrText(error)), {
movedItems
});
}
},
[rpcCall, toast, t, refreshWorkspace]
);
const onMoveFile = useCallback(
async (srcPath: string, targetDirPath: string) => {
const fileName = getSandboxPathName(srcPath);
const destPath = joinSandboxPath(targetDirPath, fileName);
if (srcPath === destPath) return;
await onMoveFiles([
{
sourcePath: srcPath,
currentParentPath: getSandboxParentPath(srcPath),
targetDirPath,
newPath: destPath
}
]);
},
[onMoveFiles]
);
// 删除文件/目录
const onDeleteFile = useCallback(
async (filePath: string) => {
......@@ -1319,11 +1381,26 @@ export const useSandboxFileStore = ({
description: getErrText(error),
status: 'error'
});
throw error;
}
},
[rpcCall, toast, t]
);
const onExecCommand = useCallback(
async (command: string, timeoutMs?: number) => {
const execTimeoutMs = timeoutMs ?? RPC_TIMEOUT_MS;
return rpcCall<ExecuteResult>(
'fs/exec',
{ command, timeoutMs },
{
timeoutMs: execTimeoutMs + 1000
}
);
},
[rpcCall]
);
// 上传文件
const onUploadFiles = useCallback(
async (files: FileList, targetDirPath: string) => {
......@@ -1489,8 +1566,10 @@ export const useSandboxFileStore = ({
onCreateNode,
onRenameComplete,
onMoveFile,
onMoveFiles,
onDeleteFile,
onUploadFiles,
onExecCommand,
toggleDirectory
};
};
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type';
export const getSafeSandboxCommandPath = (path: string) => {
const normalizedPath = path.replace(/^\.\//, '');
const segments = normalizedPath.split('/');
if (
path.startsWith('/') ||
segments.some((segment) => segment === '..') ||
normalizedPath.includes('\u0000')
) {
throw new Error('Invalid sandbox path');
}
return normalizedPath === '.' ? '.' : `./${normalizedPath}`;
};
export const getSafeSandboxPathSegment = (name: string) => {
if (!name || name === '.' || name === '..' || /[\/\\\u0000]/.test(name)) {
throw new Error('Invalid sandbox path');
}
return name;
};
export const getSandboxParentPath = (path: string) => {
const parts = path.split('/');
parts.pop();
return parts.join('/') || '.';
};
export const getSandboxPathName = (path: string) => {
const parts = path.split('/');
return parts.pop() || '';
};
export const joinSandboxPath = (parentPath: string, name: string) =>
parentPath === '.' ? name : `${parentPath}/${name}`;
/** 将 oldPath 及其子路径替换为 newPath,用于移动/重命名后的状态同步。 */
export const replacePathPrefix = (path: string, oldPath: string, newPath: string) => {
if (path === oldPath) return newPath;
if (path.startsWith(oldPath + '/')) return `${newPath}${path.slice(oldPath.length)}`;
return path;
};
/** 父子节点同时被选择时,仅保留父节点,避免重复操作同一棵子树。 */
export const getTopLevelSandboxPaths = (paths: string[]) => {
const uniquePaths = Array.from(new Set(paths)).filter((path) => path && path !== '.');
const result: string[] = [];
uniquePaths
.sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b))
.forEach((path) => {
if (!result.some((parentPath) => path.startsWith(parentPath + '/'))) {
result.push(path);
}
});
return result;
};
export type SandboxMoveOperation = {
sourcePath: string;
currentParentPath: string;
targetDirPath: string;
newPath: string;
};
export const buildSandboxMoveOperations = (
sourcePaths: string[],
targetDirPath: string
): SandboxMoveOperation[] => {
return sourcePaths
.map((sourcePath) => {
const currentParentPath = getSandboxParentPath(sourcePath);
const sourceName = getSandboxPathName(sourcePath);
const newPath = joinSandboxPath(targetDirPath, sourceName);
return {
sourcePath,
currentParentPath,
targetDirPath,
newPath
};
})
.filter((item) => item.currentParentPath !== targetDirPath);
};
export const applySandboxMoveOperationsToExpandedDirs = (
expandedDirs: Set<string>,
operations: SandboxMoveOperation[],
expandPath?: string | null
) => {
let next = new Set(expandedDirs);
operations.forEach((item) => {
const updated = new Set<string>();
next.forEach((path) => {
updated.add(replacePathPrefix(path, item.sourcePath, item.newPath));
});
next = updated;
});
if (expandPath && expandPath !== '.') {
next.add(expandPath);
}
return next;
};
export const getTargetDirectoryPath = <
T extends { type: 'file' | 'directory'; path: string; children?: T[] }
>(
tree: T[],
selectedPath: string
) => {
if (!selectedPath || selectedPath === '.') return '.';
const selectedNode = findNodeByPath(tree, selectedPath);
if (selectedNode) {
return selectedNode.type === 'directory' ? selectedPath : getSandboxParentPath(selectedPath);
}
const looksLikeFile = selectedPath.includes('.') && !selectedPath.startsWith('.');
return looksLikeFile ? getSandboxParentPath(selectedPath) : selectedPath;
};
// Get icon by filename
export const getIconByFilename = (filename: string): IconNameType => {
const ext = filename.split('.').pop()?.toLowerCase();
......
import React from 'react';
import { Box } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector';
import { useTranslation } from 'next-i18next';
import { SkillDetailContext } from './context';
import SandboxEditor from '@/pageComponents/chat/SandboxEditor/Editor';
import SandboxError from './config/SandboxError';
import { RightHeader } from '@/pageComponents/dashboard/skill/detail/Header';
import ProModal from '@/components/ProTip/ProModal';
const EDIT_DEBUG_CHAT_ID = 'edit-debug';
const Content = () => {
const { sandboxState, skillId, isSkillReady, handleSandboxError } = useContextSelector(
SkillDetailContext,
(v) => ({
const { t } = useTranslation();
const { sandboxState, skillId, isSkillReady, handleSandboxError, upgradeSandboxRuntime } =
useContextSelector(SkillDetailContext, (v) => ({
sandboxState: v.sandboxState,
skillId: v.skillId,
isSkillReady: v.isSkillReady,
handleSandboxError: v.handleSandboxError
})
);
handleSandboxError: v.handleSandboxError,
upgradeSandboxRuntime: v.upgradeSandboxRuntime
}));
const isSandboxReady = sandboxState === 'ready';
const isUpgrading = sandboxState === 'upgrading';
const isUpgradeModalOpen = sandboxState === 'upgradeRequired' || isUpgrading;
const canOperateSandbox = isSkillReady && isSandboxReady;
return (
......@@ -41,13 +45,31 @@ const Content = () => {
chatId={EDIT_DEBUG_CHAT_ID}
showFileOps={true}
showDownload={false}
showFileTreeDownload={true}
defaultViewMode={'source'}
isPreparing={!isSandboxReady}
showTerminal={true}
enablePathCopy={true}
enableZipExtract={true}
enableMultiSelect={true}
onError={(err) => handleSandboxError(err.message)}
headerRight={canOperateSandbox ? <RightHeader /> : undefined}
/>
)}
<ProModal
isOpen={isUpgradeModalOpen}
forceShow
title={t('skill:sandbox_runtime_upgrade_required')}
content={
<Box color={'myGray.900'} fontSize={'18px'} lineHeight={'26px'} mt={7}>
{t('skill:sandbox_runtime_upgrade_desc')}
</Box>
}
primaryButtonText={t('skill:sandbox_runtime_upgrade_confirm')}
primaryButtonLoading={isUpgrading}
onPrimaryClick={upgradeSandboxRuntime}
showSecondaryButton={false}
/>
</Box>
);
};
......
......@@ -9,8 +9,8 @@ import {
AgentSkillTypeEnum
} from '@fastgpt/global/core/ai/skill/constants';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getSkillDetail, streamCreateEditDebugSandbox } from '@/web/core/skill/api';
import { SkillPermission } from '@fastgpt/global/support/permission/skill/controller';
import { useSkillDebugChatStore } from './useSkillDebugChatStore';
export enum TabEnum {
......@@ -18,7 +18,13 @@ export enum TabEnum {
preview = 'preview'
}
export type SandboxState = 'idle' | 'loading' | 'ready' | 'failed';
export type SandboxState =
| 'idle'
| 'loading'
| 'ready'
| 'failed'
| 'upgradeRequired'
| 'upgrading';
export type SandboxLogEntry = {
timestamp: string;
......@@ -39,6 +45,7 @@ type SkillDetailContextType = {
isSkillReady: boolean;
startSandbox: () => void;
restartSandbox: () => void;
upgradeSandboxRuntime: () => void;
saveAllRef: React.MutableRefObject<(() => Promise<void>) | undefined>;
handleSandboxError: (err: string) => void;
chatId: string;
......@@ -58,6 +65,7 @@ export const SkillDetailContext = createContext<SkillDetailContextType>({
isSkillReady: false,
startSandbox: () => {},
restartSandbox: () => {},
upgradeSandboxRuntime: () => {},
saveAllRef: { current: undefined },
handleSandboxError: () => {},
chatId: '',
......@@ -71,9 +79,12 @@ const formatTimestamp = () => {
.join(':');
};
const RUNTIME_UPGRADE_POLL_INTERVAL_MS = 3000;
const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
const router = useRouter();
const { t } = useTranslation();
const { toast } = useToast();
const { skillId: querySkillId } = router.query;
const skillId = (Array.isArray(querySkillId) ? querySkillId[0] : querySkillId) ?? '';
const activeSkillId = useSkillDebugChatStore((state) => state.skillId);
......@@ -90,8 +101,15 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
const [sandboxError, setSandboxError] = useState<string | null>(null);
const abortCtrlRef = useRef<AbortController | null>(null);
const hasStartedRef = useRef(false);
const runtimeUpgradePollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveAllRef = useRef<() => Promise<void>>();
const clearRuntimeUpgradePollTimer = useCallback(() => {
if (!runtimeUpgradePollTimerRef.current) return;
clearTimeout(runtimeUpgradePollTimerRef.current);
runtimeUpgradePollTimerRef.current = null;
}, []);
useEffect(() => {
if (skillId && activeSkillId !== skillId) {
setSkillId(skillId);
......@@ -114,6 +132,9 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
downloadingPackage: t('skill:sandbox_downloading'),
uploadingPackage: t('skill:sandbox_uploading'),
extractingPackage: t('skill:sandbox_extracting'),
runtimeUpgradeRequired: t('skill:sandbox_runtime_upgrade_required'),
runtimeUpgradeArchiving: t('skill:sandbox_runtime_upgrade_archiving'),
runtimeUpgradeArchived: t('skill:sandbox_runtime_upgrade_archived'),
lazyInit: t('skill:sandbox_lazy_init'),
ready: isWarmStart ? t('skill:sandbox_ready_warm') : t('skill:sandbox_ready'),
failed: t('skill:sandbox_failed', { message: message || '' })
......@@ -123,61 +144,180 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
[t]
);
const startSandbox = useCallback(() => {
if (!skillId) return;
const startSandbox = useCallback(
(options?: { archiveForUpgrade?: boolean }) => {
if (!skillId) return;
// Abort previous if any
abortCtrlRef.current?.abort();
// Abort previous if any
abortCtrlRef.current?.abort();
clearRuntimeUpgradePollTimer();
const abortCtrl = new AbortController();
abortCtrlRef.current = abortCtrl;
const runSandboxStream = async (runOptions?: {
archiveForUpgrade?: boolean;
keepUpgradeState?: boolean;
}) => {
if (!runOptions?.keepUpgradeState) {
setSandboxState(runOptions?.archiveForUpgrade ? 'upgrading' : 'idle');
setSandboxLogs([]);
setSandboxError(null);
}
const abortCtrl = new AbortController();
abortCtrlRef.current = abortCtrl;
let hasReceivedFirstEvent = false;
let hasShownError = false;
let isRuntimeUpgradeFlow =
!!runOptions?.archiveForUpgrade || !!runOptions?.keepUpgradeState;
let shouldPollRuntimeUpgrade = false;
let shouldRestartAfterRuntimeUpgrade = false;
const showSandboxError = (err: string) => {
if (hasShownError) return;
hasShownError = true;
setSandboxError(err);
if (isRuntimeUpgradeFlow) {
toast({
status: 'error',
title: err
});
}
setSandboxState(isRuntimeUpgradeFlow ? 'upgradeRequired' : 'failed');
};
setSandboxState('idle');
setSandboxLogs([]);
setSandboxError(null);
const handleSandboxPhase = (status: SandboxStatusItemType) => {
switch (status.phase) {
case 'ready':
shouldPollRuntimeUpgrade = false;
setSandboxState('ready');
return;
case 'runtimeUpgradeRequired':
setSandboxState('upgradeRequired');
return;
case 'runtimeUpgradeArchiving':
isRuntimeUpgradeFlow = true;
setSandboxState('upgrading');
shouldPollRuntimeUpgrade = true;
return;
case 'runtimeUpgradeArchived':
isRuntimeUpgradeFlow = true;
shouldPollRuntimeUpgrade = false;
shouldRestartAfterRuntimeUpgrade = true;
setSandboxState('upgrading');
return;
case 'failed':
showSandboxError(
isRuntimeUpgradeFlow
? t('skill:sandbox_runtime_upgrade_failed')
: status.message || t('skill:sandbox_error_title')
);
return;
default:
return;
}
};
let hasReceivedFirstEvent = false;
try {
await streamCreateEditDebugSandbox({
data: {
skillId,
...(runOptions?.archiveForUpgrade ? { archiveForUpgrade: true } : {})
},
onStatus: (status) => {
// 收到第一条 SSE 消息后才从 idle 切到 loading(终端日志)
if (!hasReceivedFirstEvent) {
hasReceivedFirstEvent = true;
setSandboxState(
runOptions?.archiveForUpgrade || runOptions?.keepUpgradeState
? 'upgrading'
: 'loading'
);
}
const entry: SandboxLogEntry = {
timestamp: formatTimestamp(),
message: phaseToMessage(status),
phase: status.phase
};
setSandboxLogs((prev) => [...prev, entry]);
handleSandboxPhase(status);
},
onError: (err) => {
showSandboxError(
isRuntimeUpgradeFlow ? t('skill:sandbox_runtime_upgrade_failed') : err
);
},
abortCtrl
});
} catch (err) {
if (abortCtrl.signal.aborted) return;
showSandboxError(
isRuntimeUpgradeFlow
? t('skill:sandbox_runtime_upgrade_failed')
: typeof err === 'string'
? err
: err instanceof Error
? err.message
: String(err)
);
return;
}
streamCreateEditDebugSandbox({
data: { skillId },
onStatus: (status) => {
// 收到第一条 SSE 消息后才从 idle 切到 loading(终端日志)
if (!hasReceivedFirstEvent) {
hasReceivedFirstEvent = true;
setSandboxState('loading');
if (
shouldRestartAfterRuntimeUpgrade &&
!abortCtrl.signal.aborted &&
abortCtrlRef.current === abortCtrl
) {
await runSandboxStream({ keepUpgradeState: true });
return;
}
const entry: SandboxLogEntry = {
timestamp: formatTimestamp(),
message: phaseToMessage(status),
phase: status.phase
};
setSandboxLogs((prev) => [...prev, entry]);
if (
shouldPollRuntimeUpgrade &&
!abortCtrl.signal.aborted &&
abortCtrlRef.current === abortCtrl
) {
runtimeUpgradePollTimerRef.current = setTimeout(() => {
if (abortCtrlRef.current !== abortCtrl) return;
void runSandboxStream({ keepUpgradeState: true }).catch(() => {
if (abortCtrl.signal.aborted || abortCtrlRef.current !== abortCtrl) return;
showSandboxError(t('skill:sandbox_runtime_upgrade_failed'));
});
}, RUNTIME_UPGRADE_POLL_INTERVAL_MS);
}
};
if (status.phase === 'ready') {
setSandboxState('ready');
} else if (status.phase === 'failed') {
setSandboxError(status.message || t('skill:sandbox_error_title'));
setSandboxState('failed');
void runSandboxStream(options).catch((err) => {
if (abortCtrl.signal.aborted) return;
const message = options?.archiveForUpgrade
? t('skill:sandbox_runtime_upgrade_failed')
: typeof err === 'string'
? err
: err?.message || String(err);
setSandboxError(message);
if (options?.archiveForUpgrade) {
toast({
status: 'error',
title: message
});
}
},
onError: (err) => {
setSandboxError(err);
setSandboxState('failed');
},
abortCtrl
}).catch((err) => {
if (abortCtrl.signal.aborted) return;
setSandboxError(typeof err === 'string' ? err : err?.message || String(err));
setSandboxState('failed');
});
}, [skillId, phaseToMessage, t]);
setSandboxState(options?.archiveForUpgrade ? 'upgradeRequired' : 'failed');
});
},
[skillId, clearRuntimeUpgradePollTimer, phaseToMessage, t, toast]
);
const restartSandbox = useCallback(() => {
hasStartedRef.current = true;
startSandbox();
}, [startSandbox]);
const upgradeSandboxRuntime = useCallback(() => {
hasStartedRef.current = true;
startSandbox({ archiveForUpgrade: true });
}, [startSandbox]);
const handleSandboxError = useCallback((err: string) => {
setSandboxError(err);
setSandboxState('failed');
......@@ -259,8 +399,9 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => {
return () => {
abortCtrlRef.current?.abort();
clearRuntimeUpgradePollTimer();
};
}, []);
}, [clearRuntimeUpgradePollTimer]);
const contextValue: SkillDetailContextType = useMemo(
() => ({
......@@ -276,6 +417,7 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
isSkillReady,
startSandbox,
restartSandbox,
upgradeSandboxRuntime,
saveAllRef,
handleSandboxError,
chatId,
......@@ -293,6 +435,7 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
isSkillReady,
startSandbox,
restartSandbox,
upgradeSandboxRuntime,
handleSandboxError,
chatId,
restartChat
......
......@@ -9,6 +9,7 @@ import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/cons
import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type';
import { isValidObjectId } from 'mongoose';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
import { UserError } from '@fastgpt/global/common/error/utils';
import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
import { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/ai/skill/constants';
import {
......@@ -36,7 +37,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.flushHeaders();
try {
const { skillId, image } = parseApiInput({
const { skillId, image, archiveForUpgrade } = parseApiInput({
req,
bodySchema: CreateEditDebugSandboxBodySchema
}).body;
......@@ -84,6 +85,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
teamId,
tmbId,
image,
archiveForUpgrade,
onProgress
});
......@@ -93,7 +95,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// 请求参数错误是 API 边界可预期错误;运行时异常仍统一隐藏实现细节。
sseErrRes(
res,
getZodParseErrorInputSource(error) ? error : new Error('Failed to create sandbox')
getZodParseErrorInputSource(error) || error instanceof UserError
? error
: new Error('Failed to create sandbox')
);
res.end();
}
......
......@@ -226,7 +226,7 @@ export default ContextRender;
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['app', 'common', 'file', 'skill']))
...(await serviceSideProps(content, ['app', 'common', 'file', 'skill', 'user']))
}
};
}
......@@ -66,7 +66,7 @@ export default SkillDetail;
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['app', 'chat', 'common', 'skill']))
...(await serviceSideProps(content, ['app', 'chat', 'common', 'skill', 'user']))
}
};
}
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