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' 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 ## 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). 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' ...@@ -10,12 +75,34 @@ description: 'FastGPT V4.15.0-beta5 Release Notes'
## 🚀 New Features ## 🚀 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. 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 ## ⚙️ Improvements
1. HTML output now automatically switches to preview mode after generation, reducing the need to open the preview manually. 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 ## 🐛 Bug Fixes
1. Fixed a potential cross-resource file access risk when private S3 object keys were not bound to the already-authorized resource. 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 更新说明' ...@@ -13,6 +13,15 @@ description: 'FastGPT V4.15.0-beta5 更新说明'
CHAT_TITLE_MODEL=deepseek-v4-flash 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. 镜像变更 ### 2. 镜像变更
- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.15.0-beta5 - 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.15.0-beta5
...@@ -20,6 +29,34 @@ CHAT_TITLE_MODEL=deepseek-v4-flash ...@@ -20,6 +29,34 @@ CHAT_TITLE_MODEL=deepseek-v4-flash
- 更新 fastgpt-plugin 镜像 tag: v1.0.0-beta5 - 更新 fastgpt-plugin 镜像 tag: v1.0.0-beta5
- 更新 aiproxy 镜像 tag: v0.6.2 - 更新 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. 升级脚本 ### 3. 升级脚本
将所有旧的沙盒 workspace 归档到 s3 里,从而更彻底的释放不活跃的沙盒,旧的沙盒可能因为超时安装 zip 失败。因为旧的沙盒大部分关联的是旧的对话,不执行该脚本,直接把旧的沙盒全部移除也可以。该脚本仅影响旧的沙盒,不影响新生成沙盒。 将所有旧的沙盒 workspace 归档到 s3 里,从而更彻底的释放不活跃的沙盒,旧的沙盒可能因为超时安装 zip 失败。因为旧的沙盒大部分关联的是旧的对话,不执行该脚本,直接把旧的沙盒全部移除也可以。该脚本仅影响旧的沙盒,不影响新生成沙盒。
......
...@@ -6,7 +6,8 @@ const startCode = 510000; ...@@ -6,7 +6,8 @@ const startCode = 510000;
export enum SandboxErrEnum { export enum SandboxErrEnum {
agentSandboxPermissionDenied = 'agentSandboxPermissionDenied', agentSandboxPermissionDenied = 'agentSandboxPermissionDenied',
agentSandboxInitializing = 'agentSandboxInitializing' agentSandboxInitializing = 'agentSandboxInitializing',
runtimeUpgradeFailed = 'runtimeUpgradeFailed'
} }
const sandboxErr = [ const sandboxErr = [
...@@ -18,6 +19,10 @@ const sandboxErr = [ ...@@ -18,6 +19,10 @@ const sandboxErr = [
statusText: SandboxErrEnum.agentSandboxInitializing, statusText: SandboxErrEnum.agentSandboxInitializing,
message: i18nT('common:code_error.sandbox_error.agent_sandbox_initializing'), message: i18nT('common:code_error.sandbox_error.agent_sandbox_initializing'),
httpStatus: 409 httpStatus: 409
},
{
statusText: SandboxErrEnum.runtimeUpgradeFailed,
message: i18nT('common:code_error.sandbox_error.runtime_upgrade_failed')
} }
]; ];
......
...@@ -5,3 +5,6 @@ export const getTextValidLength = (chunk: string) => { ...@@ -5,3 +5,6 @@ export const getTextValidLength = (chunk: string) => {
export const isObjectId = (str: string) => { export const isObjectId = (str: string) => {
return /^[0-9a-fA-F]{24}$/.test(str); 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 = ...@@ -57,6 +57,10 @@ export type SandboxStatusPhase =
| 'downloadingPackage' // downloading skill package from MinIO | 'downloadingPackage' // downloading skill package from MinIO
| 'uploadingPackage' // uploading package into sandbox container | 'uploadingPackage' // uploading package into sandbox container
| 'extractingPackage' // extracting package in sandbox | '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 // Lazy-init phases
| 'lazyInit' // LLM first calls sandbox tool, triggers container creation | 'lazyInit' // LLM first calls sandbox tool, triggers container creation
// Terminal phases // Terminal phases
......
...@@ -152,7 +152,8 @@ export type ImportSkillResponse = z.infer<typeof ImportSkillResponseSchema>; ...@@ -152,7 +152,8 @@ export type ImportSkillResponse = z.infer<typeof ImportSkillResponseSchema>;
export const CreateEditDebugSandboxBodySchema = z.object({ export const CreateEditDebugSandboxBodySchema = z.object({
skillId: IdSchema, skillId: IdSchema,
image: SandboxImageConfigSchema.optional() image: SandboxImageConfigSchema.optional(),
archiveForUpgrade: z.boolean().optional()
}); });
export type CreateEditDebugSandboxBody = z.infer<typeof CreateEditDebugSandboxBodySchema>; export type CreateEditDebugSandboxBody = z.infer<typeof CreateEditDebugSandboxBodySchema>;
......
...@@ -325,7 +325,32 @@ export async function markSandboxArchiving(resource: SandboxResourceDoc, inactiv ...@@ -325,7 +325,32 @@ export async function markSandboxArchiving(resource: SandboxResourceDoc, inactiv
}, },
{ {
$set: { $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 } { new: true }
...@@ -395,6 +420,42 @@ export async function clearSandboxArchiveState(resource: SandboxResourceRef) { ...@@ -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) { export async function markSandboxRestoring(resource: SandboxResourceRef) {
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter'; import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger'; import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env'; import { serviceEnv } from '../../../../env';
import { isRedisLeaseError, withRedisLease } from '../../../../common/redis/lock'; import { isRedisLeaseError, withRedisLease } from '../../../../common/redis/lock';
import { createAgentSandboxInitializingError } from '../error'; import { createAgentSandboxInitializingError } from '../error';
import type { SandboxPrepareContext, SandboxPrepareStep } from './prepare'; import type { SandboxPrepareContext, SandboxPrepareStep } from './prepare';
import { buildRuntimeHash, shellQuote } from './utils'; import { buildRuntimeHash } from './utils';
import { import {
getRuntimeStateValue, getRuntimeStateValue,
readSandboxRuntimeState, readSandboxRuntimeState,
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter'; import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger'; import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env'; import { serviceEnv } from '../../../../env';
import { buildRuntimeHash, joinSandboxPath, shellQuote } from './utils'; import { buildRuntimeHash, joinSandboxPath } from './utils';
import { import {
getRuntimeStateValue, getRuntimeStateValue,
readSandboxRuntimeState, readSandboxRuntimeState,
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter'; import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { import {
injectInputFilesToSandbox, injectInputFilesToSandbox,
readSandboxPwd, readSandboxPwd,
...@@ -6,7 +7,6 @@ import { ...@@ -6,7 +7,6 @@ import {
type SandboxInputFile type SandboxInputFile
} from './files'; } from './files';
import { prepareSandboxRuntimeMirrors } from './mirrors'; import { prepareSandboxRuntimeMirrors } from './mirrors';
import { shellQuote } from './utils';
export type SandboxPrepareContext = { export type SandboxPrepareContext = {
sandbox: ISandbox; sandbox: ISandbox;
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter'; import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger'; import { getLogger, LogCategories } from '../../../../common/logger';
import { resolveSandboxHome } from './home'; import { resolveSandboxHome } from './home';
import { joinSandboxPath, shellQuote } from './utils'; import { joinSandboxPath } from './utils';
const logger = getLogger(LogCategories.MODULE.AI.AGENT); const logger = getLogger(LogCategories.MODULE.AI.AGENT);
......
...@@ -2,9 +2,6 @@ import { createHash } from 'crypto'; ...@@ -2,9 +2,6 @@ import { createHash } from 'crypto';
type HashContent = string | Buffer | Uint8Array; type HashContent = string | Buffer | Uint8Array;
/** Shell 单参数安全转义,用于拼接传给 sandbox 的命令。 */
export const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
/** 去掉 sandbox 路径右侧斜杠,根路径保持可继续拼接的空前缀。 */ /** 去掉 sandbox 路径右侧斜杠,根路径保持可继续拼接的空前缀。 */
export const trimSandboxPathRight = (value: string) => export const trimSandboxPathRight = (value: string) =>
value === '/' ? '' : value.replace(/\/+$/, ''); value === '/' ? '' : value.replace(/\/+$/, '');
......
import { getErrText } from '@fastgpt/global/common/error/utils'; 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 { batchRun } from '@fastgpt/global/common/system/utils';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
...@@ -10,7 +11,7 @@ import { serviceEnv } from '../../../../env'; ...@@ -10,7 +11,7 @@ import { serviceEnv } from '../../../../env';
import { getSandboxAdapterConfig } from '../provider/config'; import { getSandboxAdapterConfig } from '../provider/config';
import { connectToSandbox, disconnectSandbox } from '../provider/lifecycle'; import { connectToSandbox, disconnectSandbox } from '../provider/lifecycle';
import { getSandboxRuntimeProfile } from '../runtime/profile'; import { getSandboxRuntimeProfile } from '../runtime/profile';
import { joinSandboxPath, shellQuote } from '../runtime/utils'; import { joinSandboxPath } from '../runtime/utils';
import { import {
deleteSessionVolume, deleteSessionVolume,
getSessionVolumeConfig, getSessionVolumeConfig,
...@@ -18,11 +19,13 @@ import { ...@@ -18,11 +19,13 @@ import {
} from '../volume/service'; } from '../volume/service';
import { import {
clearSandboxArchiveState, clearSandboxArchiveState,
clearSandboxRuntimeUpgradeArchiveState,
createSandboxResourcesToArchiveCursor, createSandboxResourcesToArchiveCursor,
findSandboxInstanceArchiveState, findSandboxInstanceArchiveState,
isSandboxStillArchiving, isSandboxStillArchiving,
markSandboxArchived, markSandboxArchived,
markSandboxArchiving, markSandboxArchiving,
markSandboxArchivingForRuntimeUpgrade,
markSandboxRestored, markSandboxRestored,
markSandboxRestoring, markSandboxRestoring,
markSandboxResourceStopped, markSandboxResourceStopped,
...@@ -320,24 +323,57 @@ async function restoreWorkspaceArchive(params: { ...@@ -320,24 +323,57 @@ async function restoreWorkspaceArchive(params: {
logger.info('Sandbox workspace restored from archive', { sandboxId }); 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( async function runSandboxArchiveFlow({
resource: SandboxResourceDoc, archivingDoc,
inactiveBefore: Date, options = {},
options: SandboxArchiveOptions = {} logLabel,
): Promise<{ success: boolean; error?: string }> { rollbackResource,
const archivingDoc = await markSandboxArchiving(resource, inactiveBefore); rollbackArchiveState,
if (!archivingDoc) { beforeRemoteDelete
return { success: false, error: 'Resource was modified or occupied' }; }: SandboxArchiveFlowParams): Promise<{ success: boolean; error?: string }> {
}
let connectedSandbox: ISandbox | undefined; let connectedSandbox: ISandbox | undefined;
let remoteResourceDeleted = false; 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 { try {
const { sandbox, profile } = await connectSandboxForArchive(archivingDoc); const { sandbox, profile } = await connectSandboxForArchive(archivingDoc);
connectedSandbox = sandbox; connectedSandbox = sandbox;
...@@ -359,71 +395,35 @@ export async function archiveSandboxResource( ...@@ -359,71 +395,35 @@ export async function archiveSandboxResource(
body: archiveBuffer body: archiveBuffer
}); });
const stillArchiving = await isSandboxStillArchiving(archivingDoc, inactiveBefore); const beforeRemoteDeleteResult = await beforeRemoteDelete?.();
if (!stillArchiving) { if (beforeRemoteDeleteResult && !beforeRemoteDeleteResult.success) {
await clearSandboxArchiveState(archivingDoc); return rollbackBeforeRemoteDelete({
await getS3SandboxSource() error: beforeRemoteDeleteResult.error,
.deleteWorkspaceArchive({ deleteArchiveLog: `Failed to delete aborted ${logLabel} archive`,
sandboxId: archivingDoc.sandboxId stopLog: `Failed to stop temporary lifted sandbox after ${logLabel} archive abort`
})
.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
});
}); });
return { success: false, error: 'Archive aborted because of subsequent user activity' };
} }
try { try {
await deleteArchivedRemoteResource(archivingDoc); await deleteArchivedRemoteResource(archivingDoc);
remoteResourceDeleted = true; remoteResourceDeleted = true;
} catch (error: unknown) { } catch (error: unknown) {
logger.error('Failed to cleanup archived sandbox remote resource', { logger.error(`Failed to cleanup archived ${logLabel} remote resource`, {
sandboxId: archivingDoc.sandboxId, sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider, provider: archivingDoc.provider,
error error
}); });
const errorMessage = getErrText(error); const errorMessage = getErrText(error);
await getS3SandboxSource() return rollbackBeforeRemoteDelete({
.deleteWorkspaceArchive({ error: `Failed to delete remote resource: ${errorMessage}`,
sandboxId: archivingDoc.sandboxId deleteArchiveLog: `Failed to delete ${logLabel} archive after remote cleanup failure`,
}) stopLog: `Failed to stop temporary lifted sandbox after ${logLabel} remote cleanup failure`
.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 {
success: false,
error: `Failed to delete remote resource: ${errorMessage}`
};
} }
const archivedResult = await markSandboxArchived(archivingDoc); const archivedResult = await markSandboxArchived(archivingDoc);
if (archivedResult.matchedCount === 0) { 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, sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider provider: archivingDoc.provider
}); });
...@@ -437,13 +437,13 @@ export async function archiveSandboxResource( ...@@ -437,13 +437,13 @@ export async function archiveSandboxResource(
const errorMessage = getErrText(error); const errorMessage = getErrText(error);
if (remoteResourceDeleted) { if (remoteResourceDeleted) {
await markSandboxArchived(archivingDoc).catch((markError) => { 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, sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider, provider: archivingDoc.provider,
error: markError 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, sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider, provider: archivingDoc.provider,
error error
...@@ -451,14 +451,14 @@ export async function archiveSandboxResource( ...@@ -451,14 +451,14 @@ export async function archiveSandboxResource(
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
} }
await clearSandboxArchiveState(archivingDoc); await rollbackArchiveState();
await stopTemporaryLiftedSandbox(archivingDoc).catch((stopError) => { await stopTemporaryLiftedSandbox(rollbackResource).catch((stopError) => {
logger.error('Failed to stop temporary lifted sandbox after archive failure', { logger.error(`Failed to stop temporary lifted sandbox after ${logLabel} archive failure`, {
sandboxId: archivingDoc.sandboxId, sandboxId: archivingDoc.sandboxId,
error: stopError error: stopError
}); });
}); });
logger.error('Failed to archive sandbox', { logger.error(`Failed to archive ${logLabel}`, {
sandboxId: archivingDoc.sandboxId, sandboxId: archivingDoc.sandboxId,
provider: archivingDoc.provider, provider: archivingDoc.provider,
error error
...@@ -472,6 +472,61 @@ export async function archiveSandboxResource( ...@@ -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,并按固定批次执行实际归档。 * 流式读取待归档 sandbox,并按固定批次执行实际归档。
* *
* cursor 的 batchSize 控制 Mongo 单次返回量;归档 batch 控制远端资源同时拉起数量。 * cursor 的 batchSize 控制 Mongo 单次返回量;归档 batch 控制远端资源同时拉起数量。
......
...@@ -44,6 +44,7 @@ export const SandboxMetadataSchema = z.object({ ...@@ -44,6 +44,7 @@ export const SandboxMetadataSchema = z.object({
archive: z archive: z
.object({ .object({
state: SandboxArchiveStateSchema, state: SandboxArchiveStateSchema,
startedAt: z.coerce.date().optional(),
archivedAt: z.coerce.date().optional() archivedAt: z.coerce.date().optional()
}) })
.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 type { ISandbox, SandboxCreateSpec } from '@fastgpt-sdk/sandbox-adapter';
import { MongoAgentSkills } from '../model/schema'; import { MongoAgentSkills } from '../model/schema';
import { MongoAgentSkillsVersion } from '../version/schema'; import { MongoAgentSkillsVersion } from '../version/schema';
import { parseGitignoreRules } from '../utils'; import { parseGitignoreRules } from '../utils';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils'; import { joinSandboxPath } from '../../sandbox/runtime/utils';
import { import {
DEFAULT_GITIGNORE_CONTENT, DEFAULT_GITIGNORE_CONTENT,
validateDeployableSkillWorkspacePackage, validateDeployableSkillWorkspacePackage,
...@@ -19,6 +20,7 @@ import { getSandboxRuntimeProfile } from '../../sandbox/runtime/profile'; ...@@ -19,6 +20,7 @@ import { getSandboxRuntimeProfile } from '../../sandbox/runtime/profile';
import type { SandboxImageConfigType } from '@fastgpt/global/core/ai/skill/type'; import type { SandboxImageConfigType } from '@fastgpt/global/core/ai/skill/type';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants'; import { SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { SandboxErrEnum } from '@fastgpt/global/common/error/code/sandbox';
import { import {
connectReadySandboxByInstance, connectReadySandboxByInstance,
connectToSandbox, connectToSandbox,
...@@ -29,13 +31,14 @@ import { buildSandboxAdapter } from '../../sandbox/provider/adapter'; ...@@ -29,13 +31,14 @@ import { buildSandboxAdapter } from '../../sandbox/provider/adapter';
import type { SandboxClient } from '../../sandbox/service/runtime'; import type { SandboxClient } from '../../sandbox/service/runtime';
import { getSandboxClient } from '../../sandbox/service/runtime'; import { getSandboxClient } from '../../sandbox/service/runtime';
import { deleteSandboxResource } from '../../sandbox/service/resource'; import { deleteSandboxResource } from '../../sandbox/service/resource';
import { archiveSandboxResourceForRuntimeUpgrade } from '../../sandbox/service/archive';
import { import {
countRunningSandboxInstancesByType, countRunningSandboxInstancesByType,
deleteSandboxInstanceRecord,
findSandboxInstanceBySandboxId, findSandboxInstanceBySandboxId,
findSandboxResourcesBySourceChatTypeExcludeProvider, findSandboxResourcesBySourceChatTypeExcludeProvider,
migrateArchivedSandboxInstanceRecord, migrateArchivedSandboxInstanceRecord,
updateSandboxInstanceRecordBySandboxId updateSandboxInstanceRecordBySandboxId,
type SandboxResourceDoc
} from '../../sandbox/instance/repository'; } from '../../sandbox/instance/repository';
import { getLogger, LogCategories } from '../../../../common/logger'; import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env'; import { serviceEnv } from '../../../../env';
...@@ -57,6 +60,8 @@ import { ...@@ -57,6 +60,8 @@ import {
} from '../runtime/prepare'; } from '../runtime/prepare';
const addLog = getLogger(LogCategories.MODULE.AI.AGENT); 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 = { export type CreateEditDebugSandboxParams = {
skillId: string; skillId: string;
...@@ -64,6 +69,7 @@ export type CreateEditDebugSandboxParams = { ...@@ -64,6 +69,7 @@ export type CreateEditDebugSandboxParams = {
tmbId: string; tmbId: string;
image?: SandboxImageConfigType; image?: SandboxImageConfigType;
entrypoint?: SandboxCreateSpec['entrypoint']; entrypoint?: SandboxCreateSpec['entrypoint'];
archiveForUpgrade?: boolean;
onProgress?: (status: SandboxStatusItemType) => void; onProgress?: (status: SandboxStatusItemType) => void;
}; };
...@@ -85,7 +91,7 @@ export type CreateEditDebugSandboxResult = { ...@@ -85,7 +91,7 @@ export type CreateEditDebugSandboxResult = {
export async function createEditDebugSandbox( export async function createEditDebugSandbox(
params: CreateEditDebugSandboxParams params: CreateEditDebugSandboxParams
): Promise<CreateEditDebugSandboxResult> { ): Promise<CreateEditDebugSandboxResult> {
const { skillId, teamId, tmbId, image, entrypoint, onProgress } = params; const { skillId, teamId, tmbId, image, entrypoint, archiveForUpgrade, onProgress } = params;
try { try {
await checkTeamSandboxPermission(teamId); await checkTeamSandboxPermission(teamId);
...@@ -180,56 +186,171 @@ export async function createEditDebugSandbox( ...@@ -180,56 +186,171 @@ export async function createEditDebugSandbox(
sandboxId: existingInstance.sandboxId sandboxId: existingInstance.sandboxId
} }
: null; : null;
const shouldCleanWorkspaceBeforeDeploy = !!existingInstance;
const forceCleanupStaleSandbox = async (instance: NonNullable<typeof existingInstance>) => { const finishRuntimeUpgradePreparation = (sandboxId: string) => {
try { onProgress?.({
await deleteSandboxResource(instance, { keepVolume: true }); sandboxId,
} catch (deleteError) { phase: 'runtimeUpgradeArchived'
addLog.error('[Sandbox] Failed to delete unavailable sandbox resource', { });
sandboxId: instance.sandboxId,
error: deleteError return {
}); sandboxId,
} status: {
await deleteSandboxInstanceRecord(instance._id); state: 'UpgradePrepared'
}
};
}; };
/** const failRuntimeUpgrade = (sandboxId: string): never => {
* edit-debug 是可从当前 skill package 重建的临时工作区。 onProgress?.({
* 当历史归档记录存在但 S3 对象已缺失时,只在本入口降级重建,避免普通运行态 sandbox 静默丢数据。 sandboxId,
*/ phase: 'failed',
const isMissingSandboxArchiveError = (error: unknown): boolean => { message: RUNTIME_UPGRADE_FAILED_MESSAGE
const pending: unknown[] = [error]; });
const visited = new Set<unknown>(); throw new UserError(RUNTIME_UPGRADE_FAILED_MESSAGE);
};
while (pending.length > 0) {
const current = pending.shift(); const isRuntimeUpgradeArchivingTimedOut = (instance: SandboxResourceDoc) => {
if (!current || typeof current !== 'object' || visited.has(current)) continue; const startedAt = instance.metadata?.archive?.startedAt ?? instance.lastActiveAt;
visited.add(current); return Date.now() - startedAt.getTime() > RUNTIME_UPGRADE_ARCHIVING_TIMEOUT_MS;
};
const errorLike = current as Error & {
code?: unknown; const normalizeImage = (image?: SandboxImageConfigType | string | null) => {
Code?: unknown; if (typeof image === 'string') {
cause?: unknown; const lastColonIndex = image.lastIndexOf(':');
commandError?: unknown; 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; if (!image?.repository) return undefined;
const message = errorLike.message ?? ''; const repository = image.repository;
const tag = image.tag ?? '';
if ( if (!tag) {
errorName === 'NoSuchKey' || const lastColonIndex = repository.lastIndexOf(':');
errorCode === 'NoSuchKey' || if (lastColonIndex > 0 && !repository.slice(lastColonIndex + 1).includes('/')) {
(message.includes('NoSuchKey') && message.includes('specified key does not exist')) return {
) { repository: repository.slice(0, lastColonIndex),
return true; 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 => ({ const prepareContext = (sandbox: ISandbox): SkillPackagePrepareContext => ({
sandbox, sandbox,
workDirectory: runtimeProfile.workDirectory workDirectory: runtimeProfile.workDirectory
...@@ -290,6 +411,10 @@ export async function createEditDebugSandbox( ...@@ -290,6 +411,10 @@ export async function createEditDebugSandbox(
} }
const existingMetadata = instance.metadata || {}; const existingMetadata = instance.metadata || {};
const normalizedExistingMetadata = {
...existingMetadata,
...(runtimeImage ? { image: runtimeImage } : {})
};
await updateSandboxInstanceRecordBySandboxId({ await updateSandboxInstanceRecordBySandboxId({
provider: providerConfig.provider, provider: providerConfig.provider,
sandboxId: instance.sandboxId, sandboxId: instance.sandboxId,
...@@ -298,9 +423,9 @@ export async function createEditDebugSandbox( ...@@ -298,9 +423,9 @@ export async function createEditDebugSandbox(
userId: '', userId: '',
chatId: EDIT_DEBUG_SANDBOX_CHAT_ID, chatId: EDIT_DEBUG_SANDBOX_CHAT_ID,
metadata: preparedContext.workspaceHasContent metadata: preparedContext.workspaceHasContent
? existingMetadata ? normalizedExistingMetadata
: { : {
...existingMetadata, ...normalizedExistingMetadata,
versionId: targetVersionId, versionId: targetVersionId,
storage: { storage: {
key: currentVersion.storageKey, key: currentVersion.storageKey,
...@@ -319,15 +444,13 @@ export async function createEditDebugSandbox( ...@@ -319,15 +444,13 @@ export async function createEditDebugSandbox(
status: { state: 'Running' } status: { state: 'Running' }
}; };
} catch (error) { } catch (error) {
addLog.error('[Sandbox] Existing sandbox is unavailable, recreating edit-debug sandbox', { addLog.error('[Sandbox] Existing sandbox is unavailable', {
sandboxId: instance.sandboxId, sandboxId: instance.sandboxId,
error: getErrText(error), error: getErrText(error),
stack: error instanceof Error ? error.stack : undefined stack: error instanceof Error ? error.stack : undefined
}); });
shouldUnzipFromS3 = true; throw error;
await forceCleanupStaleSandbox(instance);
return null;
} finally { } finally {
if (sandbox) { if (sandbox) {
await disconnectSandbox(sandbox); await disconnectSandbox(sandbox);
...@@ -366,6 +489,7 @@ export async function createEditDebugSandbox( ...@@ -366,6 +489,7 @@ export async function createEditDebugSandbox(
const existingMetadata = instance.metadata || {}; const existingMetadata = instance.metadata || {};
const newMetadata = { const newMetadata = {
...existingMetadata, ...existingMetadata,
...(runtimeImage ? { image: runtimeImage } : {}),
versionId: currentVersion._id.toString(), versionId: currentVersion._id.toString(),
storage: { storage: {
key: currentVersion.storageKey, key: currentVersion.storageKey,
...@@ -416,16 +540,12 @@ export async function createEditDebugSandbox( ...@@ -416,16 +540,12 @@ export async function createEditDebugSandbox(
return await reloadExistingEditDebugSandbox(existingInstance, connectedSandbox); return await reloadExistingEditDebugSandbox(existingInstance, connectedSandbox);
} catch (error) { } catch (error) {
addLog.error( addLog.error('[Sandbox] Mismatched sandbox is offline or unavailable', {
'[Sandbox] Mismatched sandbox is offline or unavailable, falling back to full recreation', sandboxId: existingInstance.sandboxId,
{ error: getErrText(error),
sandboxId: existingInstance.sandboxId, stack: error instanceof Error ? error.stack : undefined
error: getErrText(error), });
stack: error instanceof Error ? error.stack : undefined throw error;
}
);
await forceCleanupStaleSandbox(existingInstance);
// 让流程继续往下走全新创建流程
} finally { } finally {
if (connectedSandbox) { if (connectedSandbox) {
try { try {
...@@ -445,52 +565,49 @@ export async function createEditDebugSandbox( ...@@ -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) { if (staleProviderInstances.length > 0) {
addLog.info('[Sandbox] Removing stale edit-debug sandbox records for inactive provider', { const staleInstancesToCleanup = staleProviderInstances.filter(
skillId, (instance) => instance._id !== staleRuntimeUpgradeInstance?._id
provider: providerConfig.provider, );
staleProviders: staleProviderInstances.map((item) => item.provider) if (staleInstancesToCleanup.length > 0) {
}); addLog.info('[Sandbox] Removing stale edit-debug sandbox records for inactive provider', {
await Promise.all( skillId,
staleProviderInstances.map(async (instance) => { provider: providerConfig.provider,
if (instance.metadata?.archive?.state === undefined) { staleProviders: staleInstancesToCleanup.map((item) => item.provider)
await deleteSandboxResource(instance).catch((error) => { });
addLog.error('[Sandbox] Failed to delete stale provider sandbox resource', { await Promise.all(
sandboxId: instance.sandboxId, staleInstancesToCleanup.map(async (instance) => {
provider: instance.provider, if (instance.metadata?.archive?.state === undefined) {
error 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
}); });
}); if (migratedInstance) {
await deleteSandboxInstanceRecord(instance._id); shouldUnzipFromS3 = false;
} else { archivedRestoreRecord = {
// edit-debug sandboxId 由 skillId + edit-debug 稳定生成,不随 provider 变化;只需迁移 Mongo 索引记录。 _id: migratedInstance._id,
const migratedInstance = await migrateArchivedSandboxInstanceRecord({ provider: migratedInstance.provider,
source: instance, sandboxId: migratedInstance.sandboxId
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
};
} }
} })
}) );
); }
} }
const maxEditDebug = const maxEditDebug =
...@@ -526,29 +643,7 @@ export async function createEditDebugSandbox( ...@@ -526,29 +643,7 @@ export async function createEditDebugSandbox(
} }
); );
let client: SandboxClient; const client = await createRuntimeSandboxClient();
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();
}
sandboxClient = client; sandboxClient = client;
sandbox = client.provider; sandbox = client.provider;
...@@ -560,36 +655,21 @@ export async function createEditDebugSandbox( ...@@ -560,36 +655,21 @@ export async function createEditDebugSandbox(
}); });
if (shouldUnzipFromS3) { if (shouldUnzipFromS3) {
if (existingInstance) { const prepareSteps = [
await prepareSandbox( preparePackageMirrors(),
prepareContext(client.provider), prepareWorkDirectory(),
preparePackageMirrors(), downloadSkillPackageToContext({
prepareWorkDirectory(), storageKey: currentVersion.storageKey,
downloadSkillPackageToContext({ onProgress: reportProgress(sessionId)
storageKey: currentVersion.storageKey, }),
onProgress: reportProgress(sessionId) ...(shouldCleanWorkspaceBeforeDeploy ? [emptyWorkDirectory()] : []),
}), deployDownloadedSkillPackage({
emptyWorkDirectory(), skillsRootPath: runtimeProfile.skillsRootPath,
deployDownloadedSkillPackage({ onProgress: reportProgress(sessionId)
skillsRootPath: runtimeProfile.skillsRootPath, })
onProgress: reportProgress(sessionId) ];
})
); await prepareSandbox(prepareContext(client.provider), ...prepareSteps);
} else {
await prepareSandbox(
prepareContext(client.provider),
preparePackageMirrors(),
prepareWorkDirectory(),
downloadSkillPackageToContext({
storageKey: currentVersion.storageKey,
onProgress: reportProgress(sessionId)
}),
deployDownloadedSkillPackage({
skillsRootPath: runtimeProfile.skillsRootPath,
onProgress: reportProgress(sessionId)
})
);
}
} else { } else {
addLog.info( addLog.info(
'[Sandbox] Skill sandbox resumed with existing volume, skip initial S3 download/unzip', '[Sandbox] Skill sandbox resumed with existing volume, skip initial S3 download/unzip',
...@@ -616,7 +696,7 @@ export async function createEditDebugSandbox( ...@@ -616,7 +696,7 @@ export async function createEditDebugSandbox(
teamId, teamId,
tmbId, tmbId,
sessionId, sessionId,
...(sandboxInfo.image ? { image: sandboxInfo.image } : {}), ...(runtimeImage ? { image: runtimeImage } : {}),
providerCreatedAt: sandboxInfo.createdAt, providerCreatedAt: sandboxInfo.createdAt,
storage: { storage: {
key: currentVersion.storageKey, key: currentVersion.storageKey,
......
import type { FileWriteEntry, ISandbox } from '@fastgpt-sdk/sandbox-adapter'; import type { FileWriteEntry, ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import type { import type {
BuiltinSkillSource, BuiltinSkillSource,
BuiltinSkillSourceFile BuiltinSkillSourceFile
} from '@fastgpt/global/core/ai/skill/runtime/builtin'; } from '@fastgpt/global/core/ai/skill/runtime/builtin';
import { getSandboxBuiltinSkillsRootPath } from '../../sandbox/runtime/profile/utils'; import { getSandboxBuiltinSkillsRootPath } from '../../sandbox/runtime/profile/utils';
import { buildRuntimeHash, joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils'; import { buildRuntimeHash, joinSandboxPath } from '../../sandbox/runtime/utils';
import { import {
getRuntimeStateValue, getRuntimeStateValue,
readSandboxRuntimeState, readSandboxRuntimeState,
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter'; import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { MongoAgentSkills } from '../model/schema'; import { MongoAgentSkills } from '../model/schema';
import { MongoAgentSkillsVersion } from '../version/schema'; import { MongoAgentSkillsVersion } from '../version/schema';
import { downloadSkillPackage } from '../package'; import { downloadSkillPackage } from '../package';
...@@ -6,7 +7,7 @@ import { parseSkillMarkdown, getSkillsRootPath } from '../utils'; ...@@ -6,7 +7,7 @@ import { parseSkillMarkdown, getSkillsRootPath } from '../utils';
import { getLogger, LogCategories } from '../../../../common/logger'; import { getLogger, LogCategories } from '../../../../common/logger';
import type { DeployedSkillInfo, DeployedSkillVersion } from './types'; import type { DeployedSkillInfo, DeployedSkillVersion } from './types';
import { serviceEnv } from '../../../../env'; 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 { authSkillByTmbId } from '../../../../support/permission/skill/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill'; import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter'; import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger'; import { getLogger, LogCategories } from '../../../../common/logger';
import type { DeployedSkillVersion } from './types'; import type { DeployedSkillVersion } from './types';
import { import {
buildLimitedOutputShellCommand, buildLimitedOutputShellCommand,
executeEntrypointCommand executeEntrypointCommand
} from '../../sandbox/runtime/entrypoint'; } from '../../sandbox/runtime/entrypoint';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils'; import { joinSandboxPath } from '../../sandbox/runtime/utils';
import { import {
getRuntimeStateValue, getRuntimeStateValue,
readSandboxRuntimeState, readSandboxRuntimeState,
......
import type { SandboxStatusPhase } from '@fastgpt/global/core/chat/type'; 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 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 { serviceEnv } from '../../../../env';
import { DEFAULT_GITIGNORE_CONTENT, downloadSkillPackage } from '../package'; import { DEFAULT_GITIGNORE_CONTENT, downloadSkillPackage } from '../package';
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
* *
* 这里只放无副作用的 SKILL.md 文本解析和模板拼装,不访问数据库、对象存储、sandbox 或 LLM。 * 这里只放无副作用的 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) ==================== */ /* ==================== YAML Frontmatter 解析 (原 skillMarkdown.ts) ==================== */
......
...@@ -17,8 +17,10 @@ import { ...@@ -17,8 +17,10 @@ import {
findSandboxResourcesBySourceChatIds, findSandboxResourcesBySourceChatIds,
findSkillRelatedSandboxResources, findSkillRelatedSandboxResources,
isSandboxStillArchiving, isSandboxStillArchiving,
clearSandboxRuntimeUpgradeArchiveState,
markSandboxArchived, markSandboxArchived,
markSandboxArchiving, markSandboxArchiving,
markSandboxArchivingForRuntimeUpgrade,
migrateArchivedSandboxInstanceRecord, migrateArchivedSandboxInstanceRecord,
markSandboxRestored, markSandboxRestored,
markSandboxRestoring, markSandboxRestoring,
...@@ -563,6 +565,52 @@ describe('sandbox instance helpers', () => { ...@@ -563,6 +565,52 @@ describe('sandbox instance helpers', () => {
expect(stored?.storage).toBeUndefined(); 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 () => { it('streams archive candidates by lastActiveAt descending', async () => {
const inactiveBefore = new Date('2026-02-01T00:00:00.000Z'); const inactiveBefore = new Date('2026-02-01T00:00:00.000Z');
const appId = `instance-helper-${getNanoid()}`; const appId = `instance-helper-${getNanoid()}`;
......
...@@ -18,11 +18,13 @@ const archiveMocks = vi.hoisted(() => ({ ...@@ -18,11 +18,13 @@ const archiveMocks = vi.hoisted(() => ({
getSessionVolumeConfig: vi.fn(), getSessionVolumeConfig: vi.fn(),
deleteSessionVolume: vi.fn(), deleteSessionVolume: vi.fn(),
clearSandboxArchiveState: vi.fn(), clearSandboxArchiveState: vi.fn(),
clearSandboxRuntimeUpgradeArchiveState: vi.fn(),
createSandboxResourcesToArchiveCursor: vi.fn(), createSandboxResourcesToArchiveCursor: vi.fn(),
findSandboxInstanceArchiveState: vi.fn(), findSandboxInstanceArchiveState: vi.fn(),
isSandboxStillArchiving: vi.fn(), isSandboxStillArchiving: vi.fn(),
markSandboxArchived: vi.fn(), markSandboxArchived: vi.fn(),
markSandboxArchiving: vi.fn(), markSandboxArchiving: vi.fn(),
markSandboxArchivingForRuntimeUpgrade: vi.fn(),
markSandboxRestored: vi.fn(), markSandboxRestored: vi.fn(),
markSandboxRestoring: vi.fn(), markSandboxRestoring: vi.fn(),
markSandboxResourceStopped: vi.fn(), markSandboxResourceStopped: vi.fn(),
...@@ -69,11 +71,13 @@ vi.mock('@fastgpt/service/core/ai/sandbox/volume/service', () => ({ ...@@ -69,11 +71,13 @@ vi.mock('@fastgpt/service/core/ai/sandbox/volume/service', () => ({
vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({ vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({
clearSandboxArchiveState: archiveMocks.clearSandboxArchiveState, clearSandboxArchiveState: archiveMocks.clearSandboxArchiveState,
clearSandboxRuntimeUpgradeArchiveState: archiveMocks.clearSandboxRuntimeUpgradeArchiveState,
createSandboxResourcesToArchiveCursor: archiveMocks.createSandboxResourcesToArchiveCursor, createSandboxResourcesToArchiveCursor: archiveMocks.createSandboxResourcesToArchiveCursor,
findSandboxInstanceArchiveState: archiveMocks.findSandboxInstanceArchiveState, findSandboxInstanceArchiveState: archiveMocks.findSandboxInstanceArchiveState,
isSandboxStillArchiving: archiveMocks.isSandboxStillArchiving, isSandboxStillArchiving: archiveMocks.isSandboxStillArchiving,
markSandboxArchived: archiveMocks.markSandboxArchived, markSandboxArchived: archiveMocks.markSandboxArchived,
markSandboxArchiving: archiveMocks.markSandboxArchiving, markSandboxArchiving: archiveMocks.markSandboxArchiving,
markSandboxArchivingForRuntimeUpgrade: archiveMocks.markSandboxArchivingForRuntimeUpgrade,
markSandboxRestored: archiveMocks.markSandboxRestored, markSandboxRestored: archiveMocks.markSandboxRestored,
markSandboxRestoring: archiveMocks.markSandboxRestoring, markSandboxRestoring: archiveMocks.markSandboxRestoring,
markSandboxResourceStopped: archiveMocks.markSandboxResourceStopped, markSandboxResourceStopped: archiveMocks.markSandboxResourceStopped,
...@@ -87,6 +91,7 @@ vi.mock('@fastgpt/service/core/ai/sandbox/provider/adapter', () => ({ ...@@ -87,6 +91,7 @@ vi.mock('@fastgpt/service/core/ai/sandbox/provider/adapter', () => ({
import { import {
archiveInactiveSandboxes, archiveInactiveSandboxes,
archiveSandboxResource, archiveSandboxResource,
archiveSandboxResourceForRuntimeUpgrade,
restoreArchivedSandboxBeforeUse restoreArchivedSandboxBeforeUse
} from '@fastgpt/service/core/ai/sandbox/service/archive'; } from '@fastgpt/service/core/ai/sandbox/service/archive';
...@@ -108,7 +113,7 @@ const createResource = (overrides: Partial<any> = {}) => ({ ...@@ -108,7 +113,7 @@ const createResource = (overrides: Partial<any> = {}) => ({
const createSandbox = () => 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' : '', stdout: command.includes('wc -l') ? '1\n' : command.includes("awk '{s+=$7}") ? '12\n' : '',
stderr: '', stderr: '',
exitCode: 0 exitCode: 0
...@@ -166,6 +171,7 @@ describe('sandbox archive service', () => { ...@@ -166,6 +171,7 @@ describe('sandbox archive service', () => {
archiveMocks.deleteWorkspaceArchive.mockResolvedValue(undefined); archiveMocks.deleteWorkspaceArchive.mockResolvedValue(undefined);
archiveMocks.disconnectSandbox.mockResolvedValue(undefined); archiveMocks.disconnectSandbox.mockResolvedValue(undefined);
archiveMocks.clearSandboxArchiveState.mockResolvedValue(undefined); archiveMocks.clearSandboxArchiveState.mockResolvedValue(undefined);
archiveMocks.clearSandboxRuntimeUpgradeArchiveState.mockResolvedValue(undefined);
archiveMocks.markSandboxArchived.mockResolvedValue({ matchedCount: 1, modifiedCount: 1 }); archiveMocks.markSandboxArchived.mockResolvedValue({ matchedCount: 1, modifiedCount: 1 });
archiveMocks.isSandboxStillArchiving.mockResolvedValue(true); archiveMocks.isSandboxStillArchiving.mockResolvedValue(true);
archiveMocks.markSandboxRestored.mockImplementation(async (resource, params) => ({ archiveMocks.markSandboxRestored.mockImplementation(async (resource, params) => ({
...@@ -322,6 +328,44 @@ describe('sandbox archive service', () => { ...@@ -322,6 +328,44 @@ describe('sandbox archive service', () => {
expect(archiveMocks.markSandboxArchived).not.toHaveBeenCalled(); 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 () => { it('restores archive from the current provider record before runtime use', async () => {
const archivedResource = createResource({ const archivedResource = createResource({
metadata: { metadata: {
......
...@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ ...@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
connectToSandbox: vi.fn(), connectToSandbox: vi.fn(),
disconnectSandbox: vi.fn(), disconnectSandbox: vi.fn(),
deleteWorkspaceArchive: vi.fn(), deleteWorkspaceArchive: vi.fn(),
archiveSandboxResourceForRuntimeUpgrade: vi.fn(),
prepareSandboxRuntimeMirrors: vi.fn(), prepareSandboxRuntimeMirrors: vi.fn(),
logger: { logger: {
info: vi.fn(), info: vi.fn(),
...@@ -95,6 +96,10 @@ vi.mock('@fastgpt/service/core/ai/sandbox/service/resource', () => ({ ...@@ -95,6 +96,10 @@ vi.mock('@fastgpt/service/core/ai/sandbox/service/resource', () => ({
deleteSandboxResource: vi.fn() deleteSandboxResource: vi.fn()
})); }));
vi.mock('@fastgpt/service/core/ai/sandbox/service/archive', () => ({
archiveSandboxResourceForRuntimeUpgrade: mocks.archiveSandboxResourceForRuntimeUpgrade
}));
vi.mock('@fastgpt/service/core/ai/sandbox/runtime/mirrors', () => ({ vi.mock('@fastgpt/service/core/ai/sandbox/runtime/mirrors', () => ({
prepareSandboxRuntimeMirrors: mocks.prepareSandboxRuntimeMirrors prepareSandboxRuntimeMirrors: mocks.prepareSandboxRuntimeMirrors
})); }));
...@@ -107,6 +112,7 @@ vi.mock('@fastgpt/service/common/s3/sources/sandbox', () => ({ ...@@ -107,6 +112,7 @@ vi.mock('@fastgpt/service/common/s3/sources/sandbox', () => ({
vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({ vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({
countRunningSandboxInstancesByType: vi.fn(), countRunningSandboxInstancesByType: vi.fn(),
deleteStaleRuntimeUpgradeArchivingRecord: vi.fn(),
deleteSandboxInstanceRecord: vi.fn(), deleteSandboxInstanceRecord: vi.fn(),
findSandboxInstanceBySandboxId: vi.fn(), findSandboxInstanceBySandboxId: vi.fn(),
findSandboxResourcesBySourceChatTypeExcludeProvider: vi.fn(), findSandboxResourcesBySourceChatTypeExcludeProvider: vi.fn(),
...@@ -140,12 +146,16 @@ import { ...@@ -140,12 +146,16 @@ import {
createEditDebugSandbox, createEditDebugSandbox,
packageSkillInSandbox packageSkillInSandbox
} from '@fastgpt/service/core/ai/skill/edit/sandbox'; } 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 { buildSandboxAdapter } from '@fastgpt/service/core/ai/sandbox/provider/adapter';
import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/service/runtime'; import { getSandboxClient } from '@fastgpt/service/core/ai/sandbox/service/runtime';
import { deleteSandboxResource } from '@fastgpt/service/core/ai/sandbox/service/resource'; import { deleteSandboxResource } from '@fastgpt/service/core/ai/sandbox/service/resource';
import { import {
countRunningSandboxInstancesByType, countRunningSandboxInstancesByType,
deleteStaleRuntimeUpgradeArchivingRecord,
deleteSandboxInstanceRecord, deleteSandboxInstanceRecord,
findSandboxInstanceBySandboxId, findSandboxInstanceBySandboxId,
findSandboxResourcesBySourceChatTypeExcludeProvider, findSandboxResourcesBySourceChatTypeExcludeProvider,
...@@ -410,7 +420,7 @@ describe('createEditDebugSandbox', () => { ...@@ -410,7 +420,7 @@ describe('createEditDebugSandbox', () => {
delete: vi.fn() delete: vi.fn()
} as any); } as any);
vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({ vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({
image: 'test-image', image: { repository: 'test-image' },
createdAt: new Date('2026-01-01T00:00:00.000Z'), createdAt: new Date('2026-01-01T00:00:00.000Z'),
status: { state: 'Running' } status: { state: 'Running' }
} as any); } as any);
...@@ -487,7 +497,7 @@ describe('createEditDebugSandbox', () => { ...@@ -487,7 +497,7 @@ describe('createEditDebugSandbox', () => {
delete: vi.fn() delete: vi.fn()
} as any); } as any);
vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({ vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({
image: 'test-image', image: { repository: 'test-image' },
createdAt: new Date('2026-01-01T00:00:00.000Z'), createdAt: new Date('2026-01-01T00:00:00.000Z'),
status: { state: 'Running' } status: { state: 'Running' }
} as any); } as any);
...@@ -495,9 +505,6 @@ describe('createEditDebugSandbox', () => { ...@@ -495,9 +505,6 @@ describe('createEditDebugSandbox', () => {
_id: 'doc-restored' _id: 'doc-restored'
} as any); } as any);
const { connectReadySandboxByInstance } =
await import('@fastgpt/service/core/ai/sandbox/provider/lifecycle');
await expect( await expect(
createEditDebugSandbox({ createEditDebugSandbox({
skillId, skillId,
...@@ -527,24 +534,380 @@ describe('createEditDebugSandbox', () => { ...@@ -527,24 +534,380 @@ describe('createEditDebugSandbox', () => {
expect(downloadSkillPackage).not.toHaveBeenCalled(); 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 skillId = 'skill-1';
const packageBuffer = Buffer.from('zip');
const noSuchKeyError = Object.assign(new Error('The specified key does not exist.'), { const noSuchKeyError = Object.assign(new Error('The specified key does not exist.'), {
name: 'NoSuchKey', name: 'NoSuchKey',
code: '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 = { const archivedInstance = {
_id: 'archived-current-provider-instance', _id: 'archived-current-provider-instance',
provider: 'test-provider', provider: 'test-provider',
...@@ -572,21 +935,7 @@ describe('createEditDebugSandbox', () => { ...@@ -572,21 +935,7 @@ describe('createEditDebugSandbox', () => {
vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(archivedInstance as any); vi.mocked(findSandboxInstanceBySandboxId).mockResolvedValueOnce(archivedInstance as any);
vi.mocked(findSandboxResourcesBySourceChatTypeExcludeProvider).mockResolvedValueOnce([]); vi.mocked(findSandboxResourcesBySourceChatTypeExcludeProvider).mockResolvedValueOnce([]);
vi.mocked(countRunningSandboxInstancesByType).mockResolvedValueOnce(0); vi.mocked(countRunningSandboxInstancesByType).mockResolvedValueOnce(0);
vi.mocked(getSandboxClient) vi.mocked(getSandboxClient).mockRejectedValueOnce(noSuchKeyError);
.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);
await expect( await expect(
createEditDebugSandbox({ createEditDebugSandbox({
...@@ -594,24 +943,15 @@ describe('createEditDebugSandbox', () => { ...@@ -594,24 +943,15 @@ describe('createEditDebugSandbox', () => {
teamId: 'team-1', teamId: 'team-1',
tmbId: 'tmb-1' tmbId: 'tmb-1'
}) })
).resolves.toMatchObject({ ).rejects.toThrow(noSuchKeyError);
sandboxId: `edit-debug-${skillId}`,
status: { state: 'Running' }
});
expect(getSandboxClient).toHaveBeenCalledTimes(2); expect(getSandboxClient).toHaveBeenCalledTimes(1);
expect(deleteSandboxInstanceRecord).toHaveBeenCalledWith(archivedInstance._id); expect(deleteSandboxInstanceRecord).not.toHaveBeenCalledWith(archivedInstance._id);
expect(deleteSandboxResource).not.toHaveBeenCalled(); expect(deleteSandboxResource).not.toHaveBeenCalled();
expect(mocks.deleteWorkspaceArchive).not.toHaveBeenCalled(); expect(mocks.deleteWorkspaceArchive).not.toHaveBeenCalled();
expect(downloadSkillPackage).toHaveBeenCalledWith({ expect(downloadSkillPackage).not.toHaveBeenCalled();
storageKey: 'storage-key' expect(getReadySandboxInfo).not.toHaveBeenCalled();
}); expect(updateSandboxInstanceRecordBySandboxId).not.toHaveBeenCalled();
expect(provider.writeFiles).toHaveBeenCalledWith([
{
path: '/workspace/skills/package.zip',
data: packageBuffer
}
]);
}); });
it('migrates archived stale-provider edit-debug records before restoring with the new provider', async () => { it('migrates archived stale-provider edit-debug records before restoring with the new provider', async () => {
...@@ -659,7 +999,7 @@ describe('createEditDebugSandbox', () => { ...@@ -659,7 +999,7 @@ describe('createEditDebugSandbox', () => {
delete: vi.fn() delete: vi.fn()
} as any); } as any);
vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({ vi.mocked(getReadySandboxInfo).mockResolvedValueOnce({
image: 'test-image', image: { repository: 'test-image' },
createdAt: new Date('2026-01-01T00:00:00.000Z'), createdAt: new Date('2026-01-01T00:00:00.000Z'),
status: { state: 'Running' } status: { state: 'Running' }
} as any); } as any);
......
...@@ -163,9 +163,13 @@ ...@@ -163,9 +163,13 @@
"sandbox_confirm_delete_title": "Confirm delete", "sandbox_confirm_delete_title": "Confirm delete",
"sandbox_connect_failed_max_attempts": "Too many failed attempts to connect to the sandbox. Connection stopped.", "sandbox_connect_failed_max_attempts": "Too many failed attempts to connect to the sandbox. Connection stopped.",
"sandbox_create_failed": "Create failed", "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": "Delete",
"sandbox_delete_failed": "Delete failed", "sandbox_delete_failed": "Delete failed",
"sandbox_download": "Download", "sandbox_download": "Download",
"sandbox_download_all": "Download all",
"sandbox_download_failed": "Download failed", "sandbox_download_failed": "Download failed",
"sandbox_entry_tooltip": "View Sandbox files", "sandbox_entry_tooltip": "View Sandbox files",
"sandbox_file_already_exists": "A file or folder with the same name already exists in the target directory", "sandbox_file_already_exists": "A file or folder with the same name already exists in the target directory",
...@@ -189,6 +193,7 @@ ...@@ -189,6 +193,7 @@
"sandbox_save_failed": "Save failed", "sandbox_save_failed": "Save failed",
"sandbox_search_files": "Search files", "sandbox_search_files": "Search files",
"sandbox_select_file_edit": "Select a file to edit", "sandbox_select_file_edit": "Select a file to edit",
"sandbox_selected_items_count": "{{count}} selected items",
"sandbox_starting": "Starting sandbox (1/2)", "sandbox_starting": "Starting sandbox (1/2)",
"sandbox_status_checkExisting": "Checking sandbox...", "sandbox_status_checkExisting": "Checking sandbox...",
"sandbox_status_connecting": "Connecting to sandbox...", "sandbox_status_connecting": "Connecting to sandbox...",
...@@ -202,8 +207,14 @@ ...@@ -202,8 +207,14 @@
"sandbox_status_lazyInit": "Virtual machine is running...", "sandbox_status_lazyInit": "Virtual machine is running...",
"sandbox_status_ready_cold": "Sandbox is ready", "sandbox_status_ready_cold": "Sandbox is ready",
"sandbox_status_ready_warm": "Sandbox is ready (warm start)", "sandbox_status_ready_warm": "Sandbox is ready (warm start)",
"sandbox_status_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_status_uploadingPackage": "Uploading skill package to sandbox...",
"sandbox_terminal": "Terminal", "sandbox_terminal": "Terminal",
"sandbox_unzip": "Unzip",
"sandbox_unzip_failed": "Unzip failed",
"sandbox_unzip_success": "Unzipped",
"sandbox_upload_failed": "Upload failed", "sandbox_upload_failed": "Upload failed",
"sandbox_upload_file": "Upload file", "sandbox_upload_file": "Upload file",
"sandbox_upload_too_large": "File too large", "sandbox_upload_too_large": "File too large",
......
...@@ -184,6 +184,7 @@ ...@@ -184,6 +184,7 @@
"code_error.skill_error.un_auth_skill": "Unauthorized to Operate This Skill", "code_error.skill_error.un_auth_skill": "Unauthorized to Operate This Skill",
"code_error.sandbox_error.agent_sandbox_initializing": "The virtual machine is initializing. Please try again later.", "code_error.sandbox_error.agent_sandbox_initializing": "The virtual machine is initializing. Please try again later.",
"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.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.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_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", "code_error.system_error.license_dataset_amount_limit": "Exceed the maximum number of knowledge bases in the system",
......
...@@ -63,6 +63,12 @@ ...@@ -63,6 +63,12 @@
"sandbox_uploading": "Uploading skill package to sandbox...", "sandbox_uploading": "Uploading skill package to sandbox...",
"sandbox_extracting": "Extracting skill package...", "sandbox_extracting": "Extracting skill package...",
"sandbox_lazy_init": "Initializing runtime environment...", "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": "Sandbox is ready",
"sandbox_ready_warm": "Sandbox is ready (warm start)", "sandbox_ready_warm": "Sandbox is ready (warm start)",
"sandbox_failed": "Sandbox creation failed: {{message}}", "sandbox_failed": "Sandbox creation failed: {{message}}",
......
...@@ -163,9 +163,13 @@ ...@@ -163,9 +163,13 @@
"sandbox_confirm_delete_title": "确认删除", "sandbox_confirm_delete_title": "确认删除",
"sandbox_connect_failed_max_attempts": "连接沙盒失败次数过多,已停止尝试。", "sandbox_connect_failed_max_attempts": "连接沙盒失败次数过多,已停止尝试。",
"sandbox_create_failed": "新建失败", "sandbox_create_failed": "新建失败",
"sandbox_copy_absolute_path": "复制绝对路径",
"sandbox_copy_path_failed": "复制路径失败",
"sandbox_copy_path_success": "路径已复制",
"sandbox_delete": "删除", "sandbox_delete": "删除",
"sandbox_delete_failed": "删除失败", "sandbox_delete_failed": "删除失败",
"sandbox_download": "下载", "sandbox_download": "下载",
"sandbox_download_all": "下载全部",
"sandbox_download_failed": "下载失败", "sandbox_download_failed": "下载失败",
"sandbox_entry_tooltip": "查看虚拟机文件", "sandbox_entry_tooltip": "查看虚拟机文件",
"sandbox_file_already_exists": "目标目录中已存在同名文件或文件夹", "sandbox_file_already_exists": "目标目录中已存在同名文件或文件夹",
...@@ -189,6 +193,7 @@ ...@@ -189,6 +193,7 @@
"sandbox_save_failed": "保存文件失败", "sandbox_save_failed": "保存文件失败",
"sandbox_search_files": "搜索文件", "sandbox_search_files": "搜索文件",
"sandbox_select_file_edit": "选择一个文件进行编辑", "sandbox_select_file_edit": "选择一个文件进行编辑",
"sandbox_selected_items_count": "已选 {{count}} 项",
"sandbox_starting": "沙盒启动中(1/2)", "sandbox_starting": "沙盒启动中(1/2)",
"sandbox_status_checkExisting": "正在检查沙箱环境...", "sandbox_status_checkExisting": "正在检查沙箱环境...",
"sandbox_status_connecting": "正在连接沙箱环境...", "sandbox_status_connecting": "正在连接沙箱环境...",
...@@ -202,8 +207,14 @@ ...@@ -202,8 +207,14 @@
"sandbox_status_lazyInit": "虚拟机运行中...", "sandbox_status_lazyInit": "虚拟机运行中...",
"sandbox_status_ready_cold": "沙箱环境就绪", "sandbox_status_ready_cold": "沙箱环境就绪",
"sandbox_status_ready_warm": "沙箱环境就绪(热启动)", "sandbox_status_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_status_runtimeUpgradeArchived": "虚拟机归档完成,正在重建运行环境...",
"sandbox_status_runtimeUpgradeArchiving": "正在归档旧版虚拟机...",
"sandbox_status_runtimeUpgradeRequired": "虚拟机运行环境需要升级",
"sandbox_status_uploadingPackage": "正在上传技能包到沙箱...", "sandbox_status_uploadingPackage": "正在上传技能包到沙箱...",
"sandbox_terminal": "终端", "sandbox_terminal": "终端",
"sandbox_unzip": "解压",
"sandbox_unzip_failed": "解压失败",
"sandbox_unzip_success": "解压完成",
"sandbox_upload_failed": "上传失败", "sandbox_upload_failed": "上传失败",
"sandbox_upload_file": "上传文件", "sandbox_upload_file": "上传文件",
"sandbox_upload_too_large": "文件过大", "sandbox_upload_too_large": "文件过大",
......
...@@ -184,6 +184,7 @@ ...@@ -184,6 +184,7 @@
"code_error.skill_error.un_auth_skill": "无权操作该技能", "code_error.skill_error.un_auth_skill": "无权操作该技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虚拟机正在初始化,请稍后重试。", "code_error.sandbox_error.agent_sandbox_initializing": "虚拟机正在初始化,请稍后重试。",
"code_error.sandbox_error.agent_sandbox_permission_denied": "当前应用无权使用虚拟机,请联系管理员配置。", "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.community_version_num_limit": "超出社区版数量限制,请升级商业版: https://fastgpt.in",
"code_error.system_error.license_app_amount_limit": "超出系统最大应用数量", "code_error.system_error.license_app_amount_limit": "超出系统最大应用数量",
"code_error.system_error.license_dataset_amount_limit": "超出系统最大知识库数量", "code_error.system_error.license_dataset_amount_limit": "超出系统最大知识库数量",
......
...@@ -63,6 +63,12 @@ ...@@ -63,6 +63,12 @@
"sandbox_uploading": "正在上传 Skill 包到沙箱...", "sandbox_uploading": "正在上传 Skill 包到沙箱...",
"sandbox_extracting": "正在解压 Skill 包...", "sandbox_extracting": "正在解压 Skill 包...",
"sandbox_lazy_init": "正在初始化运行环境...", "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": "沙箱环境就绪",
"sandbox_ready_warm": "沙箱环境就绪(热启动)", "sandbox_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_failed": "沙箱创建失败: {{message}}", "sandbox_failed": "沙箱创建失败: {{message}}",
......
...@@ -160,9 +160,13 @@ ...@@ -160,9 +160,13 @@
"sandbox_confirm_delete_title": "確認刪除", "sandbox_confirm_delete_title": "確認刪除",
"sandbox_connect_failed_max_attempts": "連接沙箱失敗次數過多,已停止嘗試。", "sandbox_connect_failed_max_attempts": "連接沙箱失敗次數過多,已停止嘗試。",
"sandbox_create_failed": "新建失敗", "sandbox_create_failed": "新建失敗",
"sandbox_copy_absolute_path": "複製絕對路徑",
"sandbox_copy_path_failed": "複製路徑失敗",
"sandbox_copy_path_success": "路徑已複製",
"sandbox_delete": "刪除", "sandbox_delete": "刪除",
"sandbox_delete_failed": "刪除失敗", "sandbox_delete_failed": "刪除失敗",
"sandbox_download": "下載", "sandbox_download": "下載",
"sandbox_download_all": "下載全部",
"sandbox_download_failed": "下載失敗", "sandbox_download_failed": "下載失敗",
"sandbox_entry_tooltip": "查看虛擬機器文件", "sandbox_entry_tooltip": "查看虛擬機器文件",
"sandbox_file_already_exists": "目標目錄中已存在同名文件或資料夾", "sandbox_file_already_exists": "目標目錄中已存在同名文件或資料夾",
...@@ -186,6 +190,7 @@ ...@@ -186,6 +190,7 @@
"sandbox_save_failed": "儲存文件失敗", "sandbox_save_failed": "儲存文件失敗",
"sandbox_search_files": "搜尋文件", "sandbox_search_files": "搜尋文件",
"sandbox_select_file_edit": "選擇一個文件進行編輯", "sandbox_select_file_edit": "選擇一個文件進行編輯",
"sandbox_selected_items_count": "已選 {{count}} 項",
"sandbox_starting": "沙盒啟動中(1/2)", "sandbox_starting": "沙盒啟動中(1/2)",
"sandbox_status_checkExisting": "正在檢查沙箱環境...", "sandbox_status_checkExisting": "正在檢查沙箱環境...",
"sandbox_status_connecting": "正在連接沙箱環境...", "sandbox_status_connecting": "正在連接沙箱環境...",
...@@ -199,8 +204,14 @@ ...@@ -199,8 +204,14 @@
"sandbox_status_lazyInit": "虛擬機運行中...", "sandbox_status_lazyInit": "虛擬機運行中...",
"sandbox_status_ready_cold": "沙箱環境就緒", "sandbox_status_ready_cold": "沙箱環境就緒",
"sandbox_status_ready_warm": "沙箱環境就緒(熱啟動)", "sandbox_status_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_status_runtimeUpgradeArchived": "虛擬機歸檔完成,正在重建運行環境...",
"sandbox_status_runtimeUpgradeArchiving": "正在歸檔舊版虛擬機...",
"sandbox_status_runtimeUpgradeRequired": "虛擬機運行環境需要升級",
"sandbox_status_uploadingPackage": "正在上傳技能包到沙箱...", "sandbox_status_uploadingPackage": "正在上傳技能包到沙箱...",
"sandbox_terminal": "終端機", "sandbox_terminal": "終端機",
"sandbox_unzip": "解壓",
"sandbox_unzip_failed": "解壓失敗",
"sandbox_unzip_success": "解壓完成",
"sandbox_upload_failed": "上傳失敗", "sandbox_upload_failed": "上傳失敗",
"sandbox_upload_file": "上傳文件", "sandbox_upload_file": "上傳文件",
"sandbox_upload_too_large": "文件過大", "sandbox_upload_too_large": "文件過大",
......
...@@ -182,6 +182,7 @@ ...@@ -182,6 +182,7 @@
"code_error.skill_error.un_auth_skill": "無權操作該技能", "code_error.skill_error.un_auth_skill": "無權操作該技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虛擬機正在初始化,請稍後重試。", "code_error.sandbox_error.agent_sandbox_initializing": "虛擬機正在初始化,請稍後重試。",
"code_error.sandbox_error.agent_sandbox_permission_denied": "當前應用無權使用虛擬機,請聯絡管理員配置。", "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.community_version_num_limit": "超出開源版數量限制,請升級商業版:https://fastgpt.io",
"code_error.system_error.license_app_amount_limit": "超出系統最大應用數量", "code_error.system_error.license_app_amount_limit": "超出系統最大應用數量",
"code_error.system_error.license_dataset_amount_limit": "超出系統最大知識庫數量", "code_error.system_error.license_dataset_amount_limit": "超出系統最大知識庫數量",
......
...@@ -63,6 +63,12 @@ ...@@ -63,6 +63,12 @@
"sandbox_uploading": "正在上傳 Skill 包到沙箱...", "sandbox_uploading": "正在上傳 Skill 包到沙箱...",
"sandbox_extracting": "正在解壓 Skill 包...", "sandbox_extracting": "正在解壓 Skill 包...",
"sandbox_lazy_init": "正在初始化運行環境...", "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": "沙箱環境就緒",
"sandbox_ready_warm": "沙箱環境就緒(熱啟動)", "sandbox_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_failed": "沙箱創建失敗: {{message}}", "sandbox_failed": "沙箱創建失敗: {{message}}",
......
...@@ -4,9 +4,22 @@ import MyIcon from '@fastgpt/web/components/common/Icon'; ...@@ -4,9 +4,22 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { getDocPath } from '@/web/common/system/doc'; import { getDocPath } from '@/web/common/system/doc';
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import type { ReactNode } from 'react';
import { useState } 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 { t } = useTranslation();
const { feConfigs } = useSystemStore(); const { feConfigs } = useSystemStore();
...@@ -14,14 +27,26 @@ const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => { ...@@ -14,14 +27,26 @@ const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => {
const openModal = props?.isOpen ?? isOpen; const openModal = props?.isOpen ?? isOpen;
const onClose = props?.onClose ?? (() => setIsOpen(false)); 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 <MyModal
isOpen={openModal} isOpen={openModal}
onClose={onClose} onClose={onClose}
showCloseButton={false} showCloseButton={false}
w={'400px'} w={'400px'}
minH={'392px'} minH={'392px'}
isCentered
> >
<ModalBody <ModalBody
userSelect={'none'} userSelect={'none'}
...@@ -52,50 +77,53 @@ const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => { ...@@ -52,50 +77,53 @@ const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => {
<MyIcon name={'star'} w={9} h={9} transform={'translateY(40%)'} /> <MyIcon name={'star'} w={9} h={9} transform={'translateY(40%)'} />
</Flex> </Flex>
<Box color={'myGray.900'} fontSize={'26px'} fontWeight={'bold'} lineHeight={'34px'}> <Box color={'myGray.900'} fontSize={'26px'} fontWeight={'bold'} lineHeight={'34px'}>
{t('common:pro_modal_title')} {title}
</Box> </Box>
<VStack {content || (
w={'full'} <VStack
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
w={'full'} w={'full'}
h={'48px'} color={'myGray.900'}
borderRadius={'10px'} fontSize={'18px'}
onClick={() => { alignItems={'center'}
window.open(getDocPath('/guide/version/commercial'), '_blank'); gap={0}
}} mt={7}
fontSize={'16px'}
fontWeight={'medium'}
> >
{t('common:pro_modal_unlock_button')} <Box lineHeight={'26px'}>{t('common:pro_modal_subtitle')}</Box>
</Button> <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 <Button
w={'full'} w={'full'}
h={'48px'} h={'48px'}
borderRadius={'10px'} borderRadius={'10px'}
variant={'whiteBase'} onClick={onPrimaryClick}
isLoading={primaryButtonLoading}
fontSize={'16px'} fontSize={'16px'}
fontWeight={'medium'} fontWeight={'medium'}
borderColor={'#E4E7ED'}
boxShadow={'0 2px 5px rgba(15, 23, 42, 0.06)'}
onClick={onClose}
> >
{t('common:pro_modal_later_button')} {primaryButtonText}
</Button> </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> </Flex>
</VStack> </VStack>
</ModalBody> </ModalBody>
......
...@@ -228,6 +228,9 @@ export const useChatGenerate = ({ ...@@ -228,6 +228,9 @@ export const useChatGenerate = ({
downloadingPackage: t('chat:sandbox_status_downloadingPackage'), downloadingPackage: t('chat:sandbox_status_downloadingPackage'),
uploadingPackage: t('chat:sandbox_status_uploadingPackage'), uploadingPackage: t('chat:sandbox_status_uploadingPackage'),
extractingPackage: t('chat:sandbox_status_extractingPackage'), 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') lazyInit: t('chat:sandbox_status_lazyInit')
}; };
......
...@@ -42,6 +42,7 @@ function MemberItemCard({ ...@@ -42,6 +42,7 @@ function MemberItemCard({
justifyContent="space-between" justifyContent="space-between"
alignItems="center" alignItems="center"
key={key} key={key}
minW={0}
px="1" px="1"
py="1" py="1"
gap="2" gap="2"
...@@ -63,13 +64,14 @@ function MemberItemCard({ ...@@ -63,13 +64,14 @@ function MemberItemCard({
p="1" p="1"
alignItems={'center'} alignItems={'center'}
gap="2" gap="2"
w="full" flex="1 1 0"
minW={0}
> >
{isChecked !== undefined && ( {isChecked !== undefined && (
<Checkbox isDisabled={disabled} isChecked={isChecked} pointerEvents="none" /> <Checkbox isDisabled={disabled} isChecked={isChecked} pointerEvents="none" />
)} )}
<Avatar src={avatar} w="1.5rem" borderRadius={'50%'} /> <Avatar src={avatar} w="1.5rem" flexShrink={0} borderRadius={'50%'} />
<Box flex={'1 0 0'} w={0}> <Box flex={'1 1 0'} minW={0}>
<Box fontSize={'sm'} w={'100%'} noOfLines={1}> <Box fontSize={'sm'} w={'100%'} noOfLines={1}>
{name === DefaultGroupName ? userInfo?.team.teamName : name} {name === DefaultGroupName ? userInfo?.team.teamName : name}
</Box> </Box>
...@@ -79,31 +81,40 @@ function MemberItemCard({ ...@@ -79,31 +81,40 @@ function MemberItemCard({
</Box> </Box>
</Flex> </Flex>
{showRoleSelect && ( {showRoleSelect && (
<RoleSelect <Box flex="0 1 300px" minW="160px" maxW="300px">
disabled={disabled} <RoleSelect
value={role} disabled={disabled}
Button={ value={role}
<Flex Button={
bg={'myGray.50'} <Flex
border="base" bg={'myGray.50'}
fontSize={'sm'} border="base"
borderRadius={'md'} fontSize={'sm'}
minH={'18px'} borderRadius={'md'}
w="300px" minH={'18px'}
p="1" w="full"
alignItems={'end'} p="1"
justifyContent={'space-between'} alignItems={'end'}
> justifyContent={'space-between'}
<RoleTags permission={role} /> overflow="hidden"
<Flex h="18px" alignItems={'center'} justifyContent={'center'}> >
<ChevronDownIcon fontSize="md" /> <RoleTags permission={role} />
<Flex h="18px" flexShrink={0} alignItems={'center'} justifyContent={'center'}>
<ChevronDownIcon fontSize="md" />
</Flex>
</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 ? ( {onDelete !== undefined && !disabled ? (
<MyIcon <MyIcon
name="common/closeLight" name="common/closeLight"
......
...@@ -63,7 +63,7 @@ function RoleSelect({ ...@@ -63,7 +63,7 @@ function RoleSelect({
const roleOptions = useMemo(() => { const roleOptions = useMemo(() => {
if (!permissionList) return { singleOptions: [], checkboxList: [] }; if (!permissionList) return { singleOptions: [], checkboxList: [] };
const list = Object.entries(permissionList).map(([_, value]) => { const list = Object.values(permissionList).map((value) => {
return { return {
name: value.name, name: value.name,
value: value.value, value: value.value,
...@@ -104,6 +104,8 @@ function RoleSelect({ ...@@ -104,6 +104,8 @@ function RoleSelect({
.map((item) => item.value); .map((item) => item.value);
}, [role, roleOptions.checkboxList]); }, [role, roleOptions.checkboxList]);
const menuMinWidth = typeof width === 'number' ? `${width}px !important` : width;
const onSelectRole = (newRole: RoleValueType) => { const onSelectRole = (newRole: RoleValueType) => {
if (newRole === role) return; if (newRole === role) return;
onChange(newRole); onChange(newRole);
...@@ -121,7 +123,7 @@ function RoleSelect({ ...@@ -121,7 +123,7 @@ function RoleSelect({
<Menu offset={offset} isOpen={isOpen} autoSelect={false} direction={'ltr'}> <Menu offset={offset} isOpen={isOpen} autoSelect={false} direction={'ltr'}>
<Box <Box
ref={ref} ref={ref}
w="fit-content" w={width}
onMouseEnter={() => { onMouseEnter={() => {
if (disabled) return; if (disabled) return;
if (trigger === 'hover') { if (trigger === 'hover') {
...@@ -139,6 +141,7 @@ function RoleSelect({ ...@@ -139,6 +141,7 @@ function RoleSelect({
> >
<MenuButton <MenuButton
position={'relative'} position={'relative'}
w="full"
cursor={disabled ? 'not-allowed' : 'pointer'} cursor={disabled ? 'not-allowed' : 'pointer'}
onClickCapture={() => { onClickCapture={() => {
if (trigger === 'click') { if (trigger === 'click') {
...@@ -150,7 +153,7 @@ function RoleSelect({ ...@@ -150,7 +153,7 @@ function RoleSelect({
{Button} {Button}
</MenuButton> </MenuButton>
<MenuList <MenuList
minW={isOpen ? `${width}px !important` : 0} minW={isOpen ? menuMinWidth : 0}
p="3" p="3"
border={'1px solid #fff'} border={'1px solid #fff'}
boxShadow={ boxShadow={
......
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Center, VStack, Flex } from '@chakra-ui/react'; import { Box, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { SkillDetailContext } from '../../dashboard/skill/detail/context'; import { SkillDetailContext } from '../../dashboard/skill/detail/context';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import MyBox from '@fastgpt/web/components/common/MyBox'; 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 type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import FileTree from './components/FileTree'; import FileTree from './components/FileTree';
...@@ -27,9 +24,13 @@ export type Props = { ...@@ -27,9 +24,13 @@ export type Props = {
outLinkAuthData?: OutLinkChatAuthProps; outLinkAuthData?: OutLinkChatAuthProps;
showFileOps?: boolean; showFileOps?: boolean;
showDownload?: boolean; showDownload?: boolean;
showFileTreeDownload?: boolean;
defaultViewMode?: 'source' | 'preview'; defaultViewMode?: 'source' | 'preview';
isPreparing?: boolean; isPreparing?: boolean;
showTerminal?: boolean; showTerminal?: boolean;
enablePathCopy?: boolean;
enableZipExtract?: boolean;
enableMultiSelect?: boolean;
onError?: (err: Error) => void; onError?: (err: Error) => void;
headerRight?: React.ReactNode; headerRight?: React.ReactNode;
bg?: string; bg?: string;
...@@ -42,14 +43,17 @@ const SandboxEditor = ({ ...@@ -42,14 +43,17 @@ const SandboxEditor = ({
outLinkAuthData, outLinkAuthData,
showFileOps = true, showFileOps = true,
showDownload = true, showDownload = true,
showFileTreeDownload,
defaultViewMode, defaultViewMode,
isPreparing = false, isPreparing = false,
showTerminal = false, showTerminal = false,
enablePathCopy = false,
enableZipExtract = false,
enableMultiSelect = false,
onError, onError,
headerRight, headerRight,
bg bg
}: Props) => { }: Props) => {
const { t } = useTranslation();
const saveAllRef = useContextSelector(SkillDetailContext, (v) => v.saveAllRef); const saveAllRef = useContextSelector(SkillDetailContext, (v) => v.saveAllRef);
const editorRef = useRef<SandboxEditorInstance>(); const editorRef = useRef<SandboxEditorInstance>();
const editorLayoutRef = useRef<HTMLDivElement>(null); const editorLayoutRef = useRef<HTMLDivElement>(null);
...@@ -59,6 +63,7 @@ const SandboxEditor = ({ ...@@ -59,6 +63,7 @@ const SandboxEditor = ({
() => resolveSandboxTarget({ appId, chatTarget }), () => resolveSandboxTarget({ appId, chatTarget }),
[appId, chatTarget?.appId, chatTarget?.skillId] [appId, chatTarget?.appId, chatTarget?.skillId]
); );
const fileTreeShowDownload = showFileTreeDownload ?? showDownload;
const { const {
fileTree, fileTree,
...@@ -78,6 +83,7 @@ const SandboxEditor = ({ ...@@ -78,6 +83,7 @@ const SandboxEditor = ({
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
activeFile, activeFile,
refreshWorkspace,
openFile, openFile,
closeFile, closeFile,
saveFile, saveFile,
...@@ -85,9 +91,10 @@ const SandboxEditor = ({ ...@@ -85,9 +91,10 @@ const SandboxEditor = ({
downloadCurrentFile, downloadCurrentFile,
onCreateNode, onCreateNode,
onRenameComplete, onRenameComplete,
onMoveFile, onMoveFiles,
onDeleteFile, onDeleteFile,
onUploadFiles, onUploadFiles,
onExecCommand,
toggleDirectory toggleDirectory
} = useSandboxFileStore({ } = useSandboxFileStore({
sandboxTarget, sandboxTarget,
...@@ -185,14 +192,20 @@ const SandboxEditor = ({ ...@@ -185,14 +192,20 @@ const SandboxEditor = ({
toggleDirectory={toggleDirectory} toggleDirectory={toggleDirectory}
onCreateNode={onCreateNode} onCreateNode={onCreateNode}
onRenameComplete={onRenameComplete} onRenameComplete={onRenameComplete}
onMoveFile={onMoveFile} onMoveFiles={onMoveFiles}
onDeleteFile={onDeleteFile} onDeleteFile={onDeleteFile}
onUploadFiles={onUploadFiles} onUploadFiles={onUploadFiles}
onExecCommand={onExecCommand}
onRefreshWorkspace={refreshWorkspace}
setExpandedDirs={setExpandedDirs} setExpandedDirs={setExpandedDirs}
sandboxTarget={sandboxTarget} sandboxTarget={sandboxTarget}
chatId={chatId} chatId={chatId}
outLinkAuthData={outLinkAuthData} outLinkAuthData={outLinkAuthData}
showFileOps={showFileOps} showFileOps={showFileOps}
showDownload={fileTreeShowDownload}
enablePathCopy={enablePathCopy}
enableZipExtract={enableZipExtract}
enableMultiSelect={enableMultiSelect}
isLoading={isInitialLoading} isLoading={isInitialLoading}
/> />
); );
......
...@@ -15,7 +15,9 @@ import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; ...@@ -15,7 +15,9 @@ import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { Trans, useTranslation } from 'next-i18next'; import { Trans, useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils'; 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 { i18nT } from '@fastgpt/global/common/i18n/utils';
import type { ExecuteResult } from '@fastgpt-sdk/sandbox-adapter';
import { import {
DndContext, DndContext,
useSensor, useSensor,
...@@ -28,7 +30,17 @@ import { ...@@ -28,7 +30,17 @@ import {
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import type { DragEndEvent, DragStartEvent, CollisionDetection } from '@dnd-kit/core'; import type { DragEndEvent, DragStartEvent, CollisionDetection } from '@dnd-kit/core';
import FileTreeNode, { InlineCreateNode } from './FileTreeNode'; 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 { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import type { ChatTargetInputType } from '@fastgpt/global/openapi/core/chat/api'; import type { ChatTargetInputType } from '@fastgpt/global/openapi/core/chat/api';
...@@ -86,14 +98,23 @@ type Props = { ...@@ -86,14 +98,23 @@ type Props = {
toggleDirectory: (node: TreeNode) => Promise<void> | void; toggleDirectory: (node: TreeNode) => Promise<void> | void;
onCreateNode: (parentPath: string, name: string, type: 'file' | 'directory') => Promise<void>; onCreateNode: (parentPath: string, name: string, type: 'file' | 'directory') => Promise<void>;
onRenameComplete: (oldPath: string, newName: string) => 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>; onDeleteFile: (path: string) => Promise<void>;
onUploadFiles: (files: FileList, targetDirPath: 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>>>; setExpandedDirs: React.Dispatch<React.SetStateAction<Set<string>>>;
sandboxTarget: ChatTargetInputType; sandboxTarget: ChatTargetInputType;
chatId: string; chatId: string;
outLinkAuthData?: OutLinkChatAuthProps; outLinkAuthData?: OutLinkChatAuthProps;
showFileOps?: boolean; showFileOps?: boolean;
showDownload?: boolean;
enablePathCopy?: boolean;
enableZipExtract?: boolean;
enableMultiSelect?: boolean;
isLoading?: boolean; isLoading?: boolean;
}; };
...@@ -112,6 +133,47 @@ const FileTreeSkeleton = () => { ...@@ -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 = ({ const DroppableRootBox = ({
children, children,
activeNode, activeNode,
...@@ -167,14 +229,20 @@ const FileTree = ({ ...@@ -167,14 +229,20 @@ const FileTree = ({
toggleDirectory, toggleDirectory,
onCreateNode, onCreateNode,
onRenameComplete, onRenameComplete,
onMoveFile, onMoveFiles,
onDeleteFile, onDeleteFile,
onUploadFiles, onUploadFiles,
onExecCommand,
onRefreshWorkspace,
setExpandedDirs, setExpandedDirs,
sandboxTarget, sandboxTarget,
chatId, chatId,
outLinkAuthData, outLinkAuthData,
showFileOps = true, showFileOps = true,
showDownload = true,
enablePathCopy = false,
enableZipExtract = false,
enableMultiSelect = false,
isLoading = false isLoading = false
}: Props) => { }: Props) => {
const { t } = useTranslation(['chat', 'common']); const { t } = useTranslation(['chat', 'common']);
...@@ -190,22 +258,45 @@ const FileTree = ({ ...@@ -190,22 +258,45 @@ const FileTree = ({
const [activeNode, setActiveNode] = useState<TreeNode | null>(null); const [activeNode, setActiveNode] = useState<TreeNode | null>(null);
const [activeOverPath, setActiveOverPath] = useState<string | 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 => { const getOperationSelectedPaths = (basePath: string) => {
for (const node of nodes) { if (enableMultiSelect && effectiveSelectedPaths.has(basePath)) {
if (node.path === targetPath) return node; return getTopLevelSandboxPaths(Array.from(effectiveSelectedPaths));
if (node.children) {
const res = findNodeByPath(node.children, targetPath);
if (res) return res;
}
} }
return null; return basePath === '.' ? [] : [basePath];
}; };
const getParentPath = (path: string) => { const selectSingleNode = (path: string) => {
const parts = path.split('/'); setSelectedPath(path);
parts.pop(); setSelectedPaths(new Set([path]));
return parts.join('/') || '.'; };
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 = () => { const getRealOverDestPath = () => {
...@@ -213,7 +304,7 @@ const FileTree = ({ ...@@ -213,7 +304,7 @@ const FileTree = ({
if (activeOverPath === '.') return '.'; if (activeOverPath === '.') return '.';
const node = findNodeByPath(filteredTree, activeOverPath); const node = findNodeByPath(filteredTree, activeOverPath);
if (node && node.type === 'file') { if (node && node.type === 'file') {
return getParentPath(activeOverPath); return getSandboxParentPath(activeOverPath);
} }
return activeOverPath; return activeOverPath;
}; };
...@@ -222,6 +313,11 @@ const FileTree = ({ ...@@ -222,6 +313,11 @@ const FileTree = ({
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
const node = event.active.data.current?.node as TreeNode | undefined; const node = event.active.data.current?.node as TreeNode | undefined;
if (node) { if (node) {
if (!enableMultiSelect || !effectiveSelectedPaths.has(node.path)) {
selectSingleNode(node.path);
} else {
setSelectedPath(node.path);
}
setActiveNode(node); setActiveNode(node);
setActiveOverPath(null); setActiveOverPath(null);
} }
...@@ -299,11 +395,18 @@ const FileTree = ({ ...@@ -299,11 +395,18 @@ const FileTree = ({
if (overId !== '.') { if (overId !== '.') {
const overNode = findNodeByPath(filteredTree, overId); const overNode = findNodeByPath(filteredTree, overId);
if (overNode && overNode.type === 'file') { 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({ toast({
title: t('chat:sandbox_move_failed'), title: t('chat:sandbox_move_failed'),
description: t('chat:sandbox_move_into_self_forbidden'), description: t('chat:sandbox_move_into_self_forbidden'),
...@@ -312,19 +415,20 @@ const FileTree = ({ ...@@ -312,19 +415,20 @@ const FileTree = ({
return; return;
} }
const lastSlash = srcPath.lastIndexOf('/'); const movePlan = buildSandboxMoveOperations(sourcePaths, destDirPath);
const currentParentPath = lastSlash === -1 ? '.' : srcPath.substring(0, lastSlash); const moveItems = movePlan;
if (currentParentPath === destDirPath) {
return;
}
// 计算移动后的新绝对路径,并在前端同步更新展开的文件夹列表状态,防止移动后因路径失效导致文件夹自动折叠 if (moveItems.length === 0) return;
const srcName = srcPath.substring(lastSlash + 1);
const newPath = destDirPath === '.' ? srcName : `${destDirPath}/${srcName}`;
// 校验重名冲突:目标路径是否已经存在同名节点 // 校验重名冲突:目标路径是否已经存在同名节点
const conflictNode = findNodeByPath(filteredTree, newPath); const newPathSet = new Set<string>();
if (conflictNode) { 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({ toast({
title: t('chat:sandbox_move_failed'), title: t('chat:sandbox_move_failed'),
description: t('chat:sandbox_file_already_exists'), description: t('chat:sandbox_file_already_exists'),
...@@ -336,59 +440,38 @@ const FileTree = ({ ...@@ -336,59 +440,38 @@ const FileTree = ({
const destNode = findNodeByPath(filteredTree, destDirPath); const destNode = findNodeByPath(filteredTree, destDirPath);
const shouldExpandDest = destDirPath !== '.' && destNode && destNode.loaded; const shouldExpandDest = destDirPath !== '.' && destNode && destNode.loaded;
setExpandedDirs((prev) => { try {
const next = new Set<string>(); const movedItems = await onMoveFiles(moveItems, {
prev.forEach((path) => { expandPath: shouldExpandDest ? destDirPath : null
if (path === srcPath) {
next.add(newPath);
} else if (path.startsWith(srcPath + '/')) {
next.add(newPath + path.substring(srcPath.length));
} else {
next.add(path);
}
}); });
if (shouldExpandDest) {
next.add(destDirPath);
}
return next;
});
try { const movedPathMap = new Map(movedItems.map((item) => [item.sourcePath, item.newPath]));
await onMoveFile(srcPath, destDirPath); 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) { } catch (error) {
toast({ const movedItems =
title: t('chat:sandbox_move_failed'), error instanceof Error && 'movedItems' in error
description: getErrText(error), ? ((error as Error & { movedItems?: typeof moveItems }).movedItems ?? [])
status: 'error' : [];
}); 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 = () => { const getTargetDirPathFromSelected = () => {
let parentPath = '.'; return getTargetDirectoryPath(filteredTree, selectedPath);
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;
}; };
// 上端控制区触发 // 上端控制区触发
...@@ -441,13 +524,25 @@ const FileTree = ({ ...@@ -441,13 +524,25 @@ const FileTree = ({
setExpandedDirs(new Set()); 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) => { const handleContextMenu = (e: React.MouseEvent, node: TreeNode) => {
e.preventDefault(); e.preventDefault();
if (node.path === '.') { if (node.path === '.') {
if (!showFileOps) return; if (!showFileOps) return;
} }
setSelectedPath(node.path); if (enableMultiSelect && effectiveSelectedPaths.has(node.path)) {
setSelectedPath(node.path);
} else {
selectSingleNode(node.path);
}
setContextMenu({ setContextMenu({
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
...@@ -458,6 +553,7 @@ const FileTree = ({ ...@@ -458,6 +553,7 @@ const FileTree = ({
const handleBlankContextMenu = (e: React.MouseEvent) => { const handleBlankContextMenu = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
if (!showFileOps) return; if (!showFileOps) return;
selectSingleNode('.');
setContextMenu({ setContextMenu({
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
...@@ -473,7 +569,7 @@ const FileTree = ({ ...@@ -473,7 +569,7 @@ const FileTree = ({
const handleCtxCreateFile = async () => { const handleCtxCreateFile = async () => {
if (!contextMenu) return; if (!contextMenu) return;
const node = contextMenu.node; 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 !== '.') { if (parentPath !== '.') {
const parentNode = findNodeByPath(filteredTree, parentPath); const parentNode = findNodeByPath(filteredTree, parentPath);
...@@ -494,7 +590,7 @@ const FileTree = ({ ...@@ -494,7 +590,7 @@ const FileTree = ({
const handleCtxCreateDir = async () => { const handleCtxCreateDir = async () => {
if (!contextMenu) return; if (!contextMenu) return;
const node = contextMenu.node; 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 !== '.') { if (parentPath !== '.') {
const parentNode = findNodeByPath(filteredTree, parentPath); const parentNode = findNodeByPath(filteredTree, parentPath);
...@@ -521,13 +617,20 @@ const FileTree = ({ ...@@ -521,13 +617,20 @@ const FileTree = ({
const handleCtxDelete = () => { const handleCtxDelete = () => {
if (!contextMenu) return; if (!contextMenu) return;
const node = contextMenu.node; 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); setContextMenu(null);
openConfirm({ openConfirm({
title: t('chat:sandbox_confirm_delete_title'), title: t('chat:sandbox_confirm_delete_title'),
customContent: ( customContent: (
<Trans <Trans
i18nKey={i18nT('chat:sandbox_confirm_delete_content')} i18nKey={i18nT('chat:sandbox_confirm_delete_content')}
values={{ name: node.name }} values={{ name: deleteName }}
components={{ components={{
name: <Text as="span" fontWeight="600" color="red.600" /> name: <Text as="span" fontWeight="600" color="red.600" />
}} }}
...@@ -536,13 +639,19 @@ const FileTree = ({ ...@@ -536,13 +639,19 @@ const FileTree = ({
confirmButtonVariant: 'dangerFill', confirmButtonVariant: 'dangerFill',
onConfirm: async () => { onConfirm: async () => {
try { 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) { } catch (error) {
toast({
title: t('chat:sandbox_delete_failed'),
description: getErrText(error),
status: 'error'
});
throw error; throw error;
} }
} }
...@@ -551,18 +660,94 @@ const FileTree = ({ ...@@ -551,18 +660,94 @@ const FileTree = ({
const handleCtxDownload = async () => { const handleCtxDownload = async () => {
if (!contextMenu) return; 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; const path = contextMenu.node.path;
setContextMenu(null); setContextMenu(null);
const parentPath = getSandboxParentPath(path);
try { try {
await downloadSandbox({ const result = await onExecCommand(
...sandboxTarget, buildExtractZipToNamedDirCommand({
chatId, zipPath: path,
outLinkAuthData, parentPath,
path: path 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) { } catch (error) {
toast({ toast({
title: t('chat:sandbox_download_failed'), title: t('chat:sandbox_unzip_failed'),
description: getErrText(error), description: getErrText(error),
status: 'error' status: 'error'
}); });
...@@ -578,7 +763,9 @@ const FileTree = ({ ...@@ -578,7 +763,9 @@ const FileTree = ({
loadingDirs={loadingDirs} loadingDirs={loadingDirs}
activeFilePath={activeFilePath} activeFilePath={activeFilePath}
selectedPath={selectedPath} selectedPath={selectedPath}
setSelectedPath={setSelectedPath} selectedPaths={effectiveSelectedPaths}
selectSingleNode={selectSingleNode}
toggleSelectedNode={toggleSelectedNode}
realOverDestPath={realOverDestPath} realOverDestPath={realOverDestPath}
openFile={openFile} openFile={openFile}
toggleDirectory={toggleDirectory} toggleDirectory={toggleDirectory}
...@@ -591,10 +778,15 @@ const FileTree = ({ ...@@ -591,10 +778,15 @@ const FileTree = ({
onConfirmCreate={handleConfirmCreate} onConfirmCreate={handleConfirmCreate}
onCancelCreate={() => setCreatingNode(null)} onCancelCreate={() => setCreatingNode(null)}
showFileOps={showFileOps} showFileOps={showFileOps}
enableMultiSelect={enableMultiSelect}
/> />
)); ));
}; };
const contextSelectedPaths = getContextSelectedPaths();
const isMultiContextMenu = contextSelectedPaths.length > 1;
const showContextSingleNodeOps = !isMultiContextMenu;
return ( return (
<Box <Box
flex="1" flex="1"
...@@ -856,16 +1048,38 @@ const FileTree = ({ ...@@ -856,16 +1048,38 @@ const FileTree = ({
<ContextMenuItem label={t('chat:sandbox_new_folder')} onClick={handleCtxCreateDir} /> <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 !== '.' && ( {contextMenu.node.path !== '.' && (
<> <>
{showFileOps && <Box borderBottom="1px solid" borderColor="myGray.100" my={1} />} {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_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 && ( {showFileOps && (
<> <>
<Box borderBottom="1px solid" borderColor="myGray.100" my={1} /> {(showContextSingleNodeOps || enablePathCopy || showDownload) && (
<Box borderBottom="1px solid" borderColor="myGray.100" my={1} />
)}
<ContextMenuItem <ContextMenuItem
label={t('chat:sandbox_delete')} label={t('chat:sandbox_delete')}
onClick={handleCtxDelete} onClick={handleCtxDelete}
......
...@@ -12,7 +12,9 @@ type Props = { ...@@ -12,7 +12,9 @@ type Props = {
loadingDirs: Set<string>; loadingDirs: Set<string>;
activeFilePath: string; activeFilePath: string;
selectedPath: string; selectedPath: string;
setSelectedPath: (path: string) => void; selectedPaths: Set<string>;
selectSingleNode: (path: string) => void;
toggleSelectedNode: (path: string) => void;
realOverDestPath: string | null; realOverDestPath: string | null;
openFile: (path: string) => void; openFile: (path: string) => void;
toggleDirectory: (node: TreeNode) => void; toggleDirectory: (node: TreeNode) => void;
...@@ -25,6 +27,7 @@ type Props = { ...@@ -25,6 +27,7 @@ type Props = {
onConfirmCreate: (name: string) => void; onConfirmCreate: (name: string) => void;
onCancelCreate: () => void; onCancelCreate: () => void;
showFileOps?: boolean; showFileOps?: boolean;
enableMultiSelect?: boolean;
}; };
const FileTreeNode = ({ const FileTreeNode = ({
...@@ -33,7 +36,9 @@ const FileTreeNode = ({ ...@@ -33,7 +36,9 @@ const FileTreeNode = ({
loadingDirs, loadingDirs,
activeFilePath, activeFilePath,
selectedPath, selectedPath,
setSelectedPath, selectedPaths,
selectSingleNode,
toggleSelectedNode,
realOverDestPath, realOverDestPath,
openFile, openFile,
toggleDirectory, toggleDirectory,
...@@ -45,12 +50,14 @@ const FileTreeNode = ({ ...@@ -45,12 +50,14 @@ const FileTreeNode = ({
renderTreeNodes, renderTreeNodes,
onConfirmCreate, onConfirmCreate,
onCancelCreate, onCancelCreate,
showFileOps = true showFileOps = true,
enableMultiSelect = false
}: Props) => { }: Props) => {
const isExpanded = expandedDirs.has(node.path); const isExpanded = expandedDirs.has(node.path);
const isLoading = loadingDirs.has(node.path); const isLoading = loadingDirs.has(node.path);
const isActive = node.type === 'file' && activeFilePath === 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 isRenaming = renamingPath === node.path;
const shouldShowArrow = node.type === 'directory'; const shouldShowArrow = node.type === 'directory';
...@@ -75,6 +82,14 @@ const FileTreeNode = ({ ...@@ -75,6 +82,14 @@ const FileTreeNode = ({
// 完美VSCode拖放体验:只允许文件夹节点和根目录Box显示放置高亮 // 完美VSCode拖放体验:只允许文件夹节点和根目录Box显示放置高亮
const isOverNode = node.type === 'directory' && (isOver || realOverDestPath === node.path); 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 ( return (
<Box> <Box>
...@@ -88,22 +103,20 @@ const FileTreeNode = ({ ...@@ -88,22 +103,20 @@ const FileTreeNode = ({
h="28px" h="28px"
cursor="pointer" cursor="pointer"
opacity={isDragging ? 0.4 : 1} opacity={isDragging ? 0.4 : 1}
_hover={{ bg: 'myGray.05' }} _hover={{ bg: hoverBg }}
bg={ bg={nodeBg}
isOverNode
? 'rgba(56, 139, 253, 0.08)'
: isSelected
? 'primary.100'
: isActive
? 'myGray.05'
: 'transparent'
}
border={isOverNode ? '1px dashed #2B5FD9' : '1px solid transparent'} border={isOverNode ? '1px dashed #2B5FD9' : '1px solid transparent'}
borderRadius="xs" borderRadius="xs"
onClick={() => { onClick={(e) => {
if (!node || !node.path) return; if (!node || !node.path) return;
if (enableMultiSelect && (e.ctrlKey || e.metaKey)) {
toggleSelectedNode(node.path);
return;
}
selectSingleNode(node.path);
if (node.type === 'file') { if (node.type === 'file') {
setSelectedPath(node.path);
openFile(node.path); openFile(node.path);
} else { } else {
toggleDirectory(node); toggleDirectory(node);
......
...@@ -18,6 +18,7 @@ import type { TreeNode } from './components/FileTree'; ...@@ -18,6 +18,7 @@ import type { TreeNode } from './components/FileTree';
import type { OpenedFile } from './components/FileTabs'; import type { OpenedFile } from './components/FileTabs';
import type { ChatTargetInputType } from '@fastgpt/global/openapi/core/chat/api'; import type { ChatTargetInputType } from '@fastgpt/global/openapi/core/chat/api';
import { getSandboxTargetId, resolveSandboxTarget } from './types'; import { getSandboxTargetId, resolveSandboxTarget } from './types';
import type { ExecuteResult } from '@fastgpt-sdk/sandbox-adapter';
import { import {
getLanguageByFileName, getLanguageByFileName,
getIsBinaryByLanguage, getIsBinaryByLanguage,
...@@ -27,7 +28,13 @@ import { ...@@ -27,7 +28,13 @@ import {
renameTreeNodeInTree, renameTreeNodeInTree,
findNodeByPath, findNodeByPath,
sortTreeNodes, sortTreeNodes,
updateTreeNode updateTreeNode,
replacePathPrefix,
getSandboxPathName,
getSandboxParentPath,
joinSandboxPath,
applySandboxMoveOperationsToExpandedDirs,
type SandboxMoveOperation
} from './utils'; } from './utils';
const SYSTEM_FILE_NAMES = ['.DS_Store']; const SYSTEM_FILE_NAMES = ['.DS_Store'];
...@@ -73,12 +80,6 @@ const encodeBase64 = (content: string) => { ...@@ -73,12 +80,6 @@ const encodeBase64 = (content: string) => {
const isValidPathSegment = (name: string) => const isValidPathSegment = (name: string) =>
!!name && name !== '.' && name !== '..' && !INVALID_PATH_SEGMENT_CHARS.test(name); !!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 * useSandboxEditor —— UI Hook
* *
...@@ -408,7 +409,11 @@ export const useSandboxFileStore = ({ ...@@ -408,7 +409,11 @@ export const useSandboxFileStore = ({
// RPC 调用 // RPC 调用
const rpcCall = useCallback( 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) { if (stoppedConnectErrorRef.current) {
throw stoppedConnectErrorRef.current; throw stoppedConnectErrorRef.current;
} }
...@@ -426,7 +431,7 @@ export const useSandboxFileStore = ({ ...@@ -426,7 +431,7 @@ export const useSandboxFileStore = ({
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
pendingRpcRequestsRef.current.delete(id); pendingRpcRequestsRef.current.delete(id);
reject(new Error(`Sandbox RPC timeout: ${method}`)); reject(new Error(`Sandbox RPC timeout: ${method}`));
}, RPC_TIMEOUT_MS); }, options.timeoutMs ?? RPC_TIMEOUT_MS);
pendingRpcRequestsRef.current.set(id, { pendingRpcRequestsRef.current.set(id, {
resolve: (res) => { resolve: (res) => {
...@@ -1228,10 +1233,8 @@ export const useSandboxFileStore = ({ ...@@ -1228,10 +1233,8 @@ export const useSandboxFileStore = ({
} }
const oldName = oldPath.split('/').pop() || ''; const oldName = oldPath.split('/').pop() || '';
const parts = oldPath.split('/'); const parentPath = getSandboxParentPath(oldPath);
parts.pop(); const newPath = joinSandboxPath(parentPath, newName);
const parentPath = parts.join('/');
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
if (oldPath === newPath) return; if (oldPath === newPath) return;
...@@ -1271,25 +1274,34 @@ export const useSandboxFileStore = ({ ...@@ -1271,25 +1274,34 @@ export const useSandboxFileStore = ({
); );
// 移动文件/目录(拖拽移动) (乐观更新) // 移动文件/目录(拖拽移动) (乐观更新)
const onMoveFile = useCallback( const onMoveFiles = useCallback(
async (srcPath: string, targetDirPath: string) => { async (operations: SandboxMoveOperation[], options?: { expandPath?: string | null }) => {
const parts = srcPath.split('/'); if (operations.length === 0) return [];
const fileName = parts.pop() || '';
const srcParentPath = parts.join('/') || '.';
const destPath = targetDirPath === '.' ? fileName : `${targetDirPath}/${fileName}`;
if (srcPath === destPath) return;
// 1. 乐观更新 operations.forEach((item) => {
updateStatePaths(srcPath, destPath); updateStatePaths(item.sourcePath, item.newPath);
setFileTree((prevTree) => moveTreeNodeInTree(prevTree, srcPath, targetDirPath)); });
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 { try {
await rpcCall('fs/move', { for (const item of operations) {
from: srcPath, await rpcCall('fs/move', {
to: destPath from: item.sourcePath,
}); to: item.newPath
});
movedItems.push(item);
}
return movedItems;
} catch (error) { } catch (error) {
console.error('Failed to move file:', error); console.error('Failed to move file:', error);
toast({ toast({
...@@ -1297,14 +1309,64 @@ export const useSandboxFileStore = ({ ...@@ -1297,14 +1309,64 @@ export const useSandboxFileStore = ({
description: getErrText(error), description: getErrText(error),
status: '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 }); await refreshWorkspace({ preserveExpandedDirs: true });
throw Object.assign(error instanceof Error ? error : new Error(getErrText(error)), {
movedItems
});
} }
}, },
[rpcCall, toast, t, refreshWorkspace] [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( const onDeleteFile = useCallback(
async (filePath: string) => { async (filePath: string) => {
...@@ -1319,11 +1381,26 @@ export const useSandboxFileStore = ({ ...@@ -1319,11 +1381,26 @@ export const useSandboxFileStore = ({
description: getErrText(error), description: getErrText(error),
status: 'error' status: 'error'
}); });
throw error;
} }
}, },
[rpcCall, toast, t] [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( const onUploadFiles = useCallback(
async (files: FileList, targetDirPath: string) => { async (files: FileList, targetDirPath: string) => {
...@@ -1489,8 +1566,10 @@ export const useSandboxFileStore = ({ ...@@ -1489,8 +1566,10 @@ export const useSandboxFileStore = ({
onCreateNode, onCreateNode,
onRenameComplete, onRenameComplete,
onMoveFile, onMoveFile,
onMoveFiles,
onDeleteFile, onDeleteFile,
onUploadFiles, onUploadFiles,
onExecCommand,
toggleDirectory toggleDirectory
}; };
}; };
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type'; 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 // Get icon by filename
export const getIconByFilename = (filename: string): IconNameType => { export const getIconByFilename = (filename: string): IconNameType => {
const ext = filename.split('.').pop()?.toLowerCase(); const ext = filename.split('.').pop()?.toLowerCase();
......
import React from 'react'; import React from 'react';
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { useTranslation } from 'next-i18next';
import { SkillDetailContext } from './context'; import { SkillDetailContext } from './context';
import SandboxEditor from '@/pageComponents/chat/SandboxEditor/Editor'; import SandboxEditor from '@/pageComponents/chat/SandboxEditor/Editor';
import SandboxError from './config/SandboxError'; import SandboxError from './config/SandboxError';
import { RightHeader } from '@/pageComponents/dashboard/skill/detail/Header'; import { RightHeader } from '@/pageComponents/dashboard/skill/detail/Header';
import ProModal from '@/components/ProTip/ProModal';
const EDIT_DEBUG_CHAT_ID = 'edit-debug'; const EDIT_DEBUG_CHAT_ID = 'edit-debug';
const Content = () => { const Content = () => {
const { sandboxState, skillId, isSkillReady, handleSandboxError } = useContextSelector( const { t } = useTranslation();
SkillDetailContext, const { sandboxState, skillId, isSkillReady, handleSandboxError, upgradeSandboxRuntime } =
(v) => ({ useContextSelector(SkillDetailContext, (v) => ({
sandboxState: v.sandboxState, sandboxState: v.sandboxState,
skillId: v.skillId, skillId: v.skillId,
isSkillReady: v.isSkillReady, isSkillReady: v.isSkillReady,
handleSandboxError: v.handleSandboxError handleSandboxError: v.handleSandboxError,
}) upgradeSandboxRuntime: v.upgradeSandboxRuntime
); }));
const isSandboxReady = sandboxState === 'ready'; const isSandboxReady = sandboxState === 'ready';
const isUpgrading = sandboxState === 'upgrading';
const isUpgradeModalOpen = sandboxState === 'upgradeRequired' || isUpgrading;
const canOperateSandbox = isSkillReady && isSandboxReady; const canOperateSandbox = isSkillReady && isSandboxReady;
return ( return (
...@@ -41,13 +45,31 @@ const Content = () => { ...@@ -41,13 +45,31 @@ const Content = () => {
chatId={EDIT_DEBUG_CHAT_ID} chatId={EDIT_DEBUG_CHAT_ID}
showFileOps={true} showFileOps={true}
showDownload={false} showDownload={false}
showFileTreeDownload={true}
defaultViewMode={'source'} defaultViewMode={'source'}
isPreparing={!isSandboxReady} isPreparing={!isSandboxReady}
showTerminal={true} showTerminal={true}
enablePathCopy={true}
enableZipExtract={true}
enableMultiSelect={true}
onError={(err) => handleSandboxError(err.message)} onError={(err) => handleSandboxError(err.message)}
headerRight={canOperateSandbox ? <RightHeader /> : undefined} 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> </Box>
); );
}; };
......
...@@ -9,8 +9,8 @@ import { ...@@ -9,8 +9,8 @@ import {
AgentSkillTypeEnum AgentSkillTypeEnum
} from '@fastgpt/global/core/ai/skill/constants'; } from '@fastgpt/global/core/ai/skill/constants';
import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getSkillDetail, streamCreateEditDebugSandbox } from '@/web/core/skill/api'; import { getSkillDetail, streamCreateEditDebugSandbox } from '@/web/core/skill/api';
import { SkillPermission } from '@fastgpt/global/support/permission/skill/controller';
import { useSkillDebugChatStore } from './useSkillDebugChatStore'; import { useSkillDebugChatStore } from './useSkillDebugChatStore';
export enum TabEnum { export enum TabEnum {
...@@ -18,7 +18,13 @@ export enum TabEnum { ...@@ -18,7 +18,13 @@ export enum TabEnum {
preview = 'preview' preview = 'preview'
} }
export type SandboxState = 'idle' | 'loading' | 'ready' | 'failed'; export type SandboxState =
| 'idle'
| 'loading'
| 'ready'
| 'failed'
| 'upgradeRequired'
| 'upgrading';
export type SandboxLogEntry = { export type SandboxLogEntry = {
timestamp: string; timestamp: string;
...@@ -39,6 +45,7 @@ type SkillDetailContextType = { ...@@ -39,6 +45,7 @@ type SkillDetailContextType = {
isSkillReady: boolean; isSkillReady: boolean;
startSandbox: () => void; startSandbox: () => void;
restartSandbox: () => void; restartSandbox: () => void;
upgradeSandboxRuntime: () => void;
saveAllRef: React.MutableRefObject<(() => Promise<void>) | undefined>; saveAllRef: React.MutableRefObject<(() => Promise<void>) | undefined>;
handleSandboxError: (err: string) => void; handleSandboxError: (err: string) => void;
chatId: string; chatId: string;
...@@ -58,6 +65,7 @@ export const SkillDetailContext = createContext<SkillDetailContextType>({ ...@@ -58,6 +65,7 @@ export const SkillDetailContext = createContext<SkillDetailContextType>({
isSkillReady: false, isSkillReady: false,
startSandbox: () => {}, startSandbox: () => {},
restartSandbox: () => {}, restartSandbox: () => {},
upgradeSandboxRuntime: () => {},
saveAllRef: { current: undefined }, saveAllRef: { current: undefined },
handleSandboxError: () => {}, handleSandboxError: () => {},
chatId: '', chatId: '',
...@@ -71,9 +79,12 @@ const formatTimestamp = () => { ...@@ -71,9 +79,12 @@ const formatTimestamp = () => {
.join(':'); .join(':');
}; };
const RUNTIME_UPGRADE_POLL_INTERVAL_MS = 3000;
const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast();
const { skillId: querySkillId } = router.query; const { skillId: querySkillId } = router.query;
const skillId = (Array.isArray(querySkillId) ? querySkillId[0] : querySkillId) ?? ''; const skillId = (Array.isArray(querySkillId) ? querySkillId[0] : querySkillId) ?? '';
const activeSkillId = useSkillDebugChatStore((state) => state.skillId); const activeSkillId = useSkillDebugChatStore((state) => state.skillId);
...@@ -90,8 +101,15 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { ...@@ -90,8 +101,15 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
const [sandboxError, setSandboxError] = useState<string | null>(null); const [sandboxError, setSandboxError] = useState<string | null>(null);
const abortCtrlRef = useRef<AbortController | null>(null); const abortCtrlRef = useRef<AbortController | null>(null);
const hasStartedRef = useRef(false); const hasStartedRef = useRef(false);
const runtimeUpgradePollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveAllRef = useRef<() => Promise<void>>(); const saveAllRef = useRef<() => Promise<void>>();
const clearRuntimeUpgradePollTimer = useCallback(() => {
if (!runtimeUpgradePollTimerRef.current) return;
clearTimeout(runtimeUpgradePollTimerRef.current);
runtimeUpgradePollTimerRef.current = null;
}, []);
useEffect(() => { useEffect(() => {
if (skillId && activeSkillId !== skillId) { if (skillId && activeSkillId !== skillId) {
setSkillId(skillId); setSkillId(skillId);
...@@ -114,6 +132,9 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { ...@@ -114,6 +132,9 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
downloadingPackage: t('skill:sandbox_downloading'), downloadingPackage: t('skill:sandbox_downloading'),
uploadingPackage: t('skill:sandbox_uploading'), uploadingPackage: t('skill:sandbox_uploading'),
extractingPackage: t('skill:sandbox_extracting'), 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'), lazyInit: t('skill:sandbox_lazy_init'),
ready: isWarmStart ? t('skill:sandbox_ready_warm') : t('skill:sandbox_ready'), ready: isWarmStart ? t('skill:sandbox_ready_warm') : t('skill:sandbox_ready'),
failed: t('skill:sandbox_failed', { message: message || '' }) failed: t('skill:sandbox_failed', { message: message || '' })
...@@ -123,61 +144,180 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { ...@@ -123,61 +144,180 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
[t] [t]
); );
const startSandbox = useCallback(() => { const startSandbox = useCallback(
if (!skillId) return; (options?: { archiveForUpgrade?: boolean }) => {
if (!skillId) return;
// Abort previous if any // Abort previous if any
abortCtrlRef.current?.abort(); 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(); let hasReceivedFirstEvent = false;
abortCtrlRef.current = abortCtrl; 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'); const handleSandboxPhase = (status: SandboxStatusItemType) => {
setSandboxLogs([]); switch (status.phase) {
setSandboxError(null); 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({ if (
data: { skillId }, shouldRestartAfterRuntimeUpgrade &&
onStatus: (status) => { !abortCtrl.signal.aborted &&
// 收到第一条 SSE 消息后才从 idle 切到 loading(终端日志) abortCtrlRef.current === abortCtrl
if (!hasReceivedFirstEvent) { ) {
hasReceivedFirstEvent = true; await runSandboxStream({ keepUpgradeState: true });
setSandboxState('loading'); return;
} }
const entry: SandboxLogEntry = { if (
timestamp: formatTimestamp(), shouldPollRuntimeUpgrade &&
message: phaseToMessage(status), !abortCtrl.signal.aborted &&
phase: status.phase abortCtrlRef.current === abortCtrl
}; ) {
setSandboxLogs((prev) => [...prev, entry]); 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') { void runSandboxStream(options).catch((err) => {
setSandboxState('ready'); if (abortCtrl.signal.aborted) return;
} else if (status.phase === 'failed') { const message = options?.archiveForUpgrade
setSandboxError(status.message || t('skill:sandbox_error_title')); ? t('skill:sandbox_runtime_upgrade_failed')
setSandboxState('failed'); : typeof err === 'string'
? err
: err?.message || String(err);
setSandboxError(message);
if (options?.archiveForUpgrade) {
toast({
status: 'error',
title: message
});
} }
}, setSandboxState(options?.archiveForUpgrade ? 'upgradeRequired' : 'failed');
onError: (err) => { });
setSandboxError(err); },
setSandboxState('failed'); [skillId, clearRuntimeUpgradePollTimer, phaseToMessage, t, toast]
}, );
abortCtrl
}).catch((err) => {
if (abortCtrl.signal.aborted) return;
setSandboxError(typeof err === 'string' ? err : err?.message || String(err));
setSandboxState('failed');
});
}, [skillId, phaseToMessage, t]);
const restartSandbox = useCallback(() => { const restartSandbox = useCallback(() => {
hasStartedRef.current = true; hasStartedRef.current = true;
startSandbox(); startSandbox();
}, [startSandbox]); }, [startSandbox]);
const upgradeSandboxRuntime = useCallback(() => {
hasStartedRef.current = true;
startSandbox({ archiveForUpgrade: true });
}, [startSandbox]);
const handleSandboxError = useCallback((err: string) => { const handleSandboxError = useCallback((err: string) => {
setSandboxError(err); setSandboxError(err);
setSandboxState('failed'); setSandboxState('failed');
...@@ -259,8 +399,9 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { ...@@ -259,8 +399,9 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => { useEffect(() => {
return () => { return () => {
abortCtrlRef.current?.abort(); abortCtrlRef.current?.abort();
clearRuntimeUpgradePollTimer();
}; };
}, []); }, [clearRuntimeUpgradePollTimer]);
const contextValue: SkillDetailContextType = useMemo( const contextValue: SkillDetailContextType = useMemo(
() => ({ () => ({
...@@ -276,6 +417,7 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { ...@@ -276,6 +417,7 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
isSkillReady, isSkillReady,
startSandbox, startSandbox,
restartSandbox, restartSandbox,
upgradeSandboxRuntime,
saveAllRef, saveAllRef,
handleSandboxError, handleSandboxError,
chatId, chatId,
...@@ -293,6 +435,7 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => { ...@@ -293,6 +435,7 @@ const SkillDetailContextProvider = ({ children }: { children: ReactNode }) => {
isSkillReady, isSkillReady,
startSandbox, startSandbox,
restartSandbox, restartSandbox,
upgradeSandboxRuntime,
handleSandboxError, handleSandboxError,
chatId, chatId,
restartChat restartChat
......
...@@ -9,6 +9,7 @@ import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/cons ...@@ -9,6 +9,7 @@ import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/cons
import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type'; import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type';
import { isValidObjectId } from 'mongoose'; import { isValidObjectId } from 'mongoose';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill'; import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
import { UserError } from '@fastgpt/global/common/error/utils';
import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
import { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/ai/skill/constants'; import { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/ai/skill/constants';
import { import {
...@@ -36,7 +37,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ...@@ -36,7 +37,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.flushHeaders(); res.flushHeaders();
try { try {
const { skillId, image } = parseApiInput({ const { skillId, image, archiveForUpgrade } = parseApiInput({
req, req,
bodySchema: CreateEditDebugSandboxBodySchema bodySchema: CreateEditDebugSandboxBodySchema
}).body; }).body;
...@@ -84,6 +85,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ...@@ -84,6 +85,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
teamId, teamId,
tmbId, tmbId,
image, image,
archiveForUpgrade,
onProgress onProgress
}); });
...@@ -93,7 +95,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ...@@ -93,7 +95,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// 请求参数错误是 API 边界可预期错误;运行时异常仍统一隐藏实现细节。 // 请求参数错误是 API 边界可预期错误;运行时异常仍统一隐藏实现细节。
sseErrRes( sseErrRes(
res, res,
getZodParseErrorInputSource(error) ? error : new Error('Failed to create sandbox') getZodParseErrorInputSource(error) || error instanceof UserError
? error
: new Error('Failed to create sandbox')
); );
res.end(); res.end();
} }
......
...@@ -226,7 +226,7 @@ export default ContextRender; ...@@ -226,7 +226,7 @@ export default ContextRender;
export async function getServerSideProps(content: any) { export async function getServerSideProps(content: any) {
return { return {
props: { props: {
...(await serviceSideProps(content, ['app', 'common', 'file', 'skill'])) ...(await serviceSideProps(content, ['app', 'common', 'file', 'skill', 'user']))
} }
}; };
} }
...@@ -66,7 +66,7 @@ export default SkillDetail; ...@@ -66,7 +66,7 @@ export default SkillDetail;
export async function getServerSideProps(content: any) { export async function getServerSideProps(content: any) {
return { return {
props: { 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