Commit dcdc0682 by DigHuang Committed by GitHub

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

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

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

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

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

* fix(sandbox): prevent data loss by removing unsafe silent restore fallbacks on edit-debug sandbox upgrade or start
parent 7c54cd6b
---
title: 'V4.15.0-beta5 (In Progress)'
title: 'V4.15.0-beta5'
description: 'FastGPT V4.15.0-beta5 Release Notes'
---
## 📦 Upgrade Guide
### 1. Update Environment Variables
Add the `CHAT_TITLE_MODEL` environment variable to `fastgpt` and `fastgpt-pro`. It is used to automatically generate chat titles. For example:
```shell
CHAT_TITLE_MODEL=deepseek-v4-flash
```
If Agent Sandbox is enabled, also add the following environment variables to `fastgpt`:
```shell
# Shared with fastgpt-agent-sandbox-proxy. In production, replace it with a random secret longer than 32 characters.
AGENT_SANDBOX_PROXY_SECRET=replace_with_32_chars_random_secret
# Browser-accessible WebSocket URL for agent-sandbox-proxy. Use wss:// if it is proxied through an HTTPS domain.
AGENT_SANDBOX_PROXY_URL=ws://{{host}}:1006
```
### 2. Image Changes
- Update the fastgpt-app (FastGPT main service) image tag to v4.15.0-beta5.
- Update the fastgpt-pro (FastGPT commercial edition) image tag to v4.15.0-beta5.
- Update the fastgpt-plugin image tag to v1.0.0-beta5.
- Update the aiproxy image tag to v0.6.2.
If Agent Sandbox is enabled, also update the following images:
- Add the fastgpt-agent-sandbox-proxy image with tag v0.2.0-beta3.
- Update the fastgpt-agent-sandbox image tag to v0.2.0-beta3.
Also add the `fastgpt-agent-sandbox-proxy` service to `docker-compose.yml`. The example below uses the China Mainland image registry. For global deployments, change the image to `ghcr.io/labring/fastgpt-agent-sandbox-proxy:v0.2.0-beta3`:
```yml
fastgpt-agent-sandbox-proxy:
image: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox-proxy:v0.2.0-beta3
container_name: fastgpt-agent-sandbox-proxy
restart: always
ports:
- 1006:1006
networks:
- fastgpt
environment:
PORT: 1006
# Must exactly match AGENT_SANDBOX_PROXY_SECRET in fastgpt.
AGENT_SANDBOX_PROXY_SECRET: replace_with_32_chars_random_secret
# Internal URL of the main app container. If your service name is not fastgpt, update it accordingly.
FASTGPT_APP_URL: http://fastgpt:3000
FASTGPT_APP_REQUEST_TIMEOUT_SECS: 10
RUST_LOG: info,fastgpt_agent_sandbox_proxy=debug
# Configure this only when the upstream sandbox endpoint returns localhost/127.0.0.1 and the proxy container cannot reach it.
# AGENT_SANDBOX_PROXY_REWRITE_HOST: host.docker.internal
```
### 3. Upgrade Script
Archive all old sandbox workspaces to S3 to more thoroughly release inactive sandboxes. Some old sandboxes may fail to install zip packages because of timeouts. Because most old sandboxes are tied to old chats, you may also remove all old sandboxes directly instead of running this script. This script only affects old sandboxes and does not affect newly created sandboxes.
```shell
curl --location --request POST 'https://{{host}}/api/admin/initSandboxArchive' \
--header 'rootkey: {{rootkey}}' \
--header 'Content-Type: application/json' \
-d '{"runArchive":true,"inactiveDays":0}'
```
## Breaking Changes
1. API Key behavior has changed. FastGPT no longer distinguishes between app keys and system keys; only system keys are kept. For OpenAI SDK compatibility, pass the token as `apikey-appId`. Existing API keys remain compatible and continue to work. For details, see the [FastGPT API documentation](../../../openapi/intro).
......@@ -10,12 +75,34 @@ description: 'FastGPT V4.15.0-beta5 Release Notes'
## 🚀 New Features
1. The HTTP node now supports ignoring TLS certificate verification, which is useful when calling HTTPS services that use self-signed or internal certificates.
2. Added an environment variable for maximum folder depth to prevent unlimited nested folders.
3. Chat windows now support a quick scroll-to-bottom button.
4. Optimized streaming output animations based on Lobe UI.
5. Added model-generated chat titles. Configure the `CHAT_TITLE_MODEL` variable to enable this feature.
6. Adjusted the Skill Edit editing experience.
7. The HTTP node now supports returning the complete error object.
8. Knowledge Base search in agent mode now supports permission filtering.
9. Optimized API key logic by unifying APIKey management and requiring requests to explicitly pass the app context.
10. Optimized agent context compression.
11. Added output syntax for quick replies.
## ⚙️ Improvements
1. HTML output now automatically switches to preview mode after generation, reducing the need to open the preview manually.
2. Improved long-name display for apps, datasets, files, and folders: names are truncated when they exceed the available width, and the full name is shown when hovering over the name.
2. Improved long-name display for apps, Knowledge Bases, files, and folders: names are truncated when they exceed the available width, and the full name is shown on hover.
3. Removed `temperature` and `max_tokens` from all built-in LLM requests to avoid incompatibility with some models.
4. Improved error prompts for Knowledge Base training failures, including one-click retry for all failed items.
5. Filtered out invalid Knowledge Base citation markers.
6. When a tool returns an empty response, FastGPT now automatically fills in `"none"` to avoid errors from some models.
7. Added a second permission check before system tools run.
8. Optimized SSRF checks after redirects.
## 🐛 Bug Fixes
1. Fixed a potential cross-resource file access risk when private S3 object keys were not bound to the already-authorized resource.
2. Fixed abnormal tool call parameter schemas for `array` and `object` types in Workflow tools.
3. Fixed a UI offset issue in the portal publish channel.
## Code Improvements
1. Added a length guard for system string processing. When the string is too long, synchronous replacement stops to avoid high CPU load. You can adjust the limit with the `SYSTEM_MAX_STRING_LENGTH_M` environment variable.
......@@ -13,6 +13,15 @@ description: 'FastGPT V4.15.0-beta5 更新说明'
CHAT_TITLE_MODEL=deepseek-v4-flash
```
如果启用 Agent Sandbox,`fastgpt` 还需要增加下面环境变量:
```shell
# 与 fastgpt-agent-sandbox-proxy 共用,生产环境请改为 32 位以上随机密钥
AGENT_SANDBOX_PROXY_SECRET=replace_with_32_chars_random_secret
# 浏览器可访问的 agent-sandbox-proxy WebSocket 地址;如已通过 HTTPS 域名代理,请使用 wss://
AGENT_SANDBOX_PROXY_URL=ws://{{host}}:1006
```
### 2. 镜像变更
- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.15.0-beta5
......@@ -20,6 +29,34 @@ CHAT_TITLE_MODEL=deepseek-v4-flash
- 更新 fastgpt-plugin 镜像 tag: v1.0.0-beta5
- 更新 aiproxy 镜像 tag: v0.6.2
如果启用 Agent Sandbox,需同步更新下面镜像:
- 新增 fastgpt-agent-sandbox-proxy 镜像 tag: v0.2.0-beta3
- 更新 fastgpt-agent-sandbox 镜像 tag: v0.2.0-beta3
同时在 `docker-compose.yml` 中新增 `fastgpt-agent-sandbox-proxy` 服务。下面示例使用国内镜像源,海外部署可将镜像改为 `ghcr.io/labring/fastgpt-agent-sandbox-proxy:v0.2.0-beta3`:
```yml
fastgpt-agent-sandbox-proxy:
image: registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox-proxy:v0.2.0-beta3
container_name: fastgpt-agent-sandbox-proxy
restart: always
ports:
- 1006:1006
networks:
- fastgpt
environment:
PORT: 1006
# 必须与 fastgpt 中的 AGENT_SANDBOX_PROXY_SECRET 完全一致
AGENT_SANDBOX_PROXY_SECRET: replace_with_32_chars_random_secret
# 主站容器内网地址;如果服务名不是 fastgpt,请按实际 docker-compose 服务名调整
FASTGPT_APP_URL: http://fastgpt:3000
FASTGPT_APP_REQUEST_TIMEOUT_SECS: 10
RUST_LOG: info,fastgpt_agent_sandbox_proxy=debug
# 当上游 sandbox endpoint 返回 localhost/127.0.0.1 且 proxy 容器无法访问时再配置
# AGENT_SANDBOX_PROXY_REWRITE_HOST: host.docker.internal
```
### 3. 升级脚本
将所有旧的沙盒 workspace 归档到 s3 里,从而更彻底的释放不活跃的沙盒,旧的沙盒可能因为超时安装 zip 失败。因为旧的沙盒大部分关联的是旧的对话,不执行该脚本,直接把旧的沙盒全部移除也可以。该脚本仅影响旧的沙盒,不影响新生成沙盒。
......
......@@ -6,7 +6,8 @@ const startCode = 510000;
export enum SandboxErrEnum {
agentSandboxPermissionDenied = 'agentSandboxPermissionDenied',
agentSandboxInitializing = 'agentSandboxInitializing'
agentSandboxInitializing = 'agentSandboxInitializing',
runtimeUpgradeFailed = 'runtimeUpgradeFailed'
}
const sandboxErr = [
......@@ -18,6 +19,10 @@ const sandboxErr = [
statusText: SandboxErrEnum.agentSandboxInitializing,
message: i18nT('common:code_error.sandbox_error.agent_sandbox_initializing'),
httpStatus: 409
},
{
statusText: SandboxErrEnum.runtimeUpgradeFailed,
message: i18nT('common:code_error.sandbox_error.runtime_upgrade_failed')
}
];
......
......@@ -5,3 +5,6 @@ export const getTextValidLength = (chunk: string) => {
export const isObjectId = (str: string) => {
return /^[0-9a-fA-F]{24}$/.test(str);
};
/** Shell 单参数安全转义,用于拼接传给 sandbox 的命令。 */
export const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
......@@ -57,6 +57,10 @@ export type SandboxStatusPhase =
| 'downloadingPackage' // downloading skill package from MinIO
| 'uploadingPackage' // uploading package into sandbox container
| 'extractingPackage' // extracting package in sandbox
// Runtime image upgrade phases
| 'runtimeUpgradeRequired' // existing edit-debug sandbox uses an outdated runtime image
| 'runtimeUpgradeArchiving' // archiving workspace before recreating with current image
| 'runtimeUpgradeArchived' // outdated runtime is archived or removed; caller should refresh/restart
// Lazy-init phases
| 'lazyInit' // LLM first calls sandbox tool, triggers container creation
// Terminal phases
......
......@@ -152,7 +152,8 @@ export type ImportSkillResponse = z.infer<typeof ImportSkillResponseSchema>;
export const CreateEditDebugSandboxBodySchema = z.object({
skillId: IdSchema,
image: SandboxImageConfigSchema.optional()
image: SandboxImageConfigSchema.optional(),
archiveForUpgrade: z.boolean().optional()
});
export type CreateEditDebugSandboxBody = z.infer<typeof CreateEditDebugSandboxBodySchema>;
......
......@@ -325,7 +325,32 @@ export async function markSandboxArchiving(resource: SandboxResourceDoc, inactiv
},
{
$set: {
'metadata.archive.state': 'archiving'
'metadata.archive.state': 'archiving',
'metadata.archive.startedAt': new Date()
}
},
{ new: true }
).lean<SandboxResourceDoc | null>();
}
/**
* 用户主动升级 edit-debug runtime 时抢占实例进行归档。
*
* 该入口不依赖 inactive/stopped 判断,因为升级由 Skill detail 显式触发;
* 但仍使用 archive state CAS,避免与恢复、定时归档或重复点击并发抢同一条记录。
*/
export async function markSandboxArchivingForRuntimeUpgrade(resource: SandboxResourceDoc) {
return MongoSandboxInstance.findOneAndUpdate(
{
...buildSandboxResourceRecordFilter(resource),
lastActiveAt: resource.lastActiveAt,
'metadata.archive.state': { $exists: false }
},
{
$set: {
status: SandboxStatusEnum.stopped,
'metadata.archive.state': 'archiving',
'metadata.archive.startedAt': new Date()
}
},
{ new: true }
......@@ -395,6 +420,42 @@ export async function clearSandboxArchiveState(resource: SandboxResourceRef) {
}
/**
* 清理用户升级归档的中间状态,并恢复抢占归档前的本地 status。
*
* 升级归档会临时把 running 实例标为 stopped 来复用 archived CAS;失败时必须恢复原状态,
* 否则用户重试前 Mongo 记录会短暂呈现为 stopped。
*/
export async function clearSandboxRuntimeUpgradeArchiveState(resource: SandboxResourceDoc) {
return MongoSandboxInstance.updateOne(
{
...buildSandboxResourceRecordFilter(resource),
'metadata.archive.state': 'archiving'
},
{
$set: {
status: resource.status
},
$unset: {
'metadata.archive': ''
}
}
);
}
/**
* 清理卡在 runtime 升级归档中的 edit-debug 记录。
*
* 只允许处理仍处于 archiving 的同一条资源;调用方随后会删除远端资源并从当前发布包重建。
* 这里直接删除 Mongo 记录,避免刷新后继续命中 archiving 状态。
*/
export async function deleteStaleRuntimeUpgradeArchivingRecord(resource: SandboxResourceDoc) {
return MongoSandboxInstance.deleteOne({
...buildSandboxResourceRecordFilter(resource),
'metadata.archive.state': 'archiving'
});
}
/**
* 原子抢占一个已归档实例进行恢复。
*/
export async function markSandboxRestoring(resource: SandboxResourceRef) {
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
import { isRedisLeaseError, withRedisLease } from '../../../../common/redis/lock';
import { createAgentSandboxInitializingError } from '../error';
import type { SandboxPrepareContext, SandboxPrepareStep } from './prepare';
import { buildRuntimeHash, shellQuote } from './utils';
import { buildRuntimeHash } from './utils';
import {
getRuntimeStateValue,
readSandboxRuntimeState,
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
import { buildRuntimeHash, joinSandboxPath, shellQuote } from './utils';
import { buildRuntimeHash, joinSandboxPath } from './utils';
import {
getRuntimeStateValue,
readSandboxRuntimeState,
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import {
injectInputFilesToSandbox,
readSandboxPwd,
......@@ -6,7 +7,6 @@ import {
type SandboxInputFile
} from './files';
import { prepareSandboxRuntimeMirrors } from './mirrors';
import { shellQuote } from './utils';
export type SandboxPrepareContext = {
sandbox: ISandbox;
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import { resolveSandboxHome } from './home';
import { joinSandboxPath, shellQuote } from './utils';
import { joinSandboxPath } from './utils';
const logger = getLogger(LogCategories.MODULE.AI.AGENT);
......
......@@ -2,9 +2,6 @@ import { createHash } from 'crypto';
type HashContent = string | Buffer | Uint8Array;
/** Shell 单参数安全转义,用于拼接传给 sandbox 的命令。 */
export const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
/** 去掉 sandbox 路径右侧斜杠,根路径保持可继续拼接的空前缀。 */
export const trimSandboxPathRight = (value: string) =>
value === '/' ? '' : value.replace(/\/+$/, '');
......
......@@ -44,6 +44,7 @@ export const SandboxMetadataSchema = z.object({
archive: z
.object({
state: SandboxArchiveStateSchema,
startedAt: z.coerce.date().optional(),
archivedAt: z.coerce.date().optional()
})
.optional(),
......
import type { FileWriteEntry, ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import type {
BuiltinSkillSource,
BuiltinSkillSourceFile
} from '@fastgpt/global/core/ai/skill/runtime/builtin';
import { getSandboxBuiltinSkillsRootPath } from '../../sandbox/runtime/profile/utils';
import { buildRuntimeHash, joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import { buildRuntimeHash, joinSandboxPath } from '../../sandbox/runtime/utils';
import {
getRuntimeStateValue,
readSandboxRuntimeState,
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { MongoAgentSkills } from '../model/schema';
import { MongoAgentSkillsVersion } from '../version/schema';
import { downloadSkillPackage } from '../package';
......@@ -6,7 +7,7 @@ import { parseSkillMarkdown, getSkillsRootPath } from '../utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import type { DeployedSkillInfo, DeployedSkillVersion } from './types';
import { serviceEnv } from '../../../../env';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import { joinSandboxPath } from '../../sandbox/runtime/utils';
import { authSkillByTmbId } from '../../../../support/permission/skill/auth';
import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import type { DeployedSkillVersion } from './types';
import {
buildLimitedOutputShellCommand,
executeEntrypointCommand
} from '../../sandbox/runtime/entrypoint';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import { joinSandboxPath } from '../../sandbox/runtime/utils';
import {
getRuntimeStateValue,
readSandboxRuntimeState,
......
import type { SandboxStatusPhase } from '@fastgpt/global/core/chat/type';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import type { SandboxPrepareContext, SandboxPrepareStep } from '../../sandbox/runtime/prepare';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import { joinSandboxPath } from '../../sandbox/runtime/utils';
import { serviceEnv } from '../../../../env';
import { DEFAULT_GITIGNORE_CONTENT, downloadSkillPackage } from '../package';
......
......@@ -3,7 +3,8 @@
*
* 这里只放无副作用的 SKILL.md 文本解析和模板拼装,不访问数据库、对象存储、sandbox 或 LLM。
*/
import { joinSandboxPath, shellQuote } from '../sandbox/runtime/utils';
import { shellQuote } from '@fastgpt/global/common/string/utils';
import { joinSandboxPath } from '../sandbox/runtime/utils';
/* ==================== YAML Frontmatter 解析 (原 skillMarkdown.ts) ==================== */
......
......@@ -17,8 +17,10 @@ import {
findSandboxResourcesBySourceChatIds,
findSkillRelatedSandboxResources,
isSandboxStillArchiving,
clearSandboxRuntimeUpgradeArchiveState,
markSandboxArchived,
markSandboxArchiving,
markSandboxArchivingForRuntimeUpgrade,
migrateArchivedSandboxInstanceRecord,
markSandboxRestored,
markSandboxRestoring,
......@@ -563,6 +565,52 @@ describe('sandbox instance helpers', () => {
expect(stored?.storage).toBeUndefined();
});
it('claims running records for runtime upgrade archive and restores original status on rollback', async () => {
const sandboxId = `instance-helper-${getNanoid()}`;
const doc = await MongoSandboxInstance.create({
provider: 'opensandbox',
sandboxId,
sourceType: ChatSourceTypeEnum.app,
sourceId: `instance-helper-${getNanoid()}`,
userId: 'user-1',
chatId: 'edit-debug',
type: SandboxTypeEnum.editDebug,
status: SandboxStatusEnum.running,
lastActiveAt: new Date('2026-01-01T00:00:00.000Z'),
createdAt: new Date(),
metadata: {
image: { repository: 'old-image' }
}
});
const archiving = await markSandboxArchivingForRuntimeUpgrade(doc);
expect(archiving).toMatchObject({
status: SandboxStatusEnum.stopped,
metadata: {
archive: {
state: 'archiving'
}
}
});
await expect(
markSandboxArchivingForRuntimeUpgrade({
...doc.toObject(),
lastActiveAt: new Date('2025-01-01T00:00:00.000Z')
} as SandboxResourceDoc)
).resolves.toBeNull();
await clearSandboxRuntimeUpgradeArchiveState(doc);
const stored = await MongoSandboxInstance.findOne({ sandboxId }).lean();
expect(stored).toMatchObject({
status: SandboxStatusEnum.running,
metadata: {
image: { repository: 'old-image' }
}
});
expect(stored?.metadata?.archive).toBeUndefined();
});
it('streams archive candidates by lastActiveAt descending', async () => {
const inactiveBefore = new Date('2026-02-01T00:00:00.000Z');
const appId = `instance-helper-${getNanoid()}`;
......
......@@ -18,11 +18,13 @@ const archiveMocks = vi.hoisted(() => ({
getSessionVolumeConfig: vi.fn(),
deleteSessionVolume: vi.fn(),
clearSandboxArchiveState: vi.fn(),
clearSandboxRuntimeUpgradeArchiveState: vi.fn(),
createSandboxResourcesToArchiveCursor: vi.fn(),
findSandboxInstanceArchiveState: vi.fn(),
isSandboxStillArchiving: vi.fn(),
markSandboxArchived: vi.fn(),
markSandboxArchiving: vi.fn(),
markSandboxArchivingForRuntimeUpgrade: vi.fn(),
markSandboxRestored: vi.fn(),
markSandboxRestoring: vi.fn(),
markSandboxResourceStopped: vi.fn(),
......@@ -69,11 +71,13 @@ vi.mock('@fastgpt/service/core/ai/sandbox/volume/service', () => ({
vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({
clearSandboxArchiveState: archiveMocks.clearSandboxArchiveState,
clearSandboxRuntimeUpgradeArchiveState: archiveMocks.clearSandboxRuntimeUpgradeArchiveState,
createSandboxResourcesToArchiveCursor: archiveMocks.createSandboxResourcesToArchiveCursor,
findSandboxInstanceArchiveState: archiveMocks.findSandboxInstanceArchiveState,
isSandboxStillArchiving: archiveMocks.isSandboxStillArchiving,
markSandboxArchived: archiveMocks.markSandboxArchived,
markSandboxArchiving: archiveMocks.markSandboxArchiving,
markSandboxArchivingForRuntimeUpgrade: archiveMocks.markSandboxArchivingForRuntimeUpgrade,
markSandboxRestored: archiveMocks.markSandboxRestored,
markSandboxRestoring: archiveMocks.markSandboxRestoring,
markSandboxResourceStopped: archiveMocks.markSandboxResourceStopped,
......@@ -87,6 +91,7 @@ vi.mock('@fastgpt/service/core/ai/sandbox/provider/adapter', () => ({
import {
archiveInactiveSandboxes,
archiveSandboxResource,
archiveSandboxResourceForRuntimeUpgrade,
restoreArchivedSandboxBeforeUse
} from '@fastgpt/service/core/ai/sandbox/service/archive';
......@@ -108,7 +113,7 @@ const createResource = (overrides: Partial<any> = {}) => ({
const createSandbox = () =>
({
execute: vi.fn(async (command: string, _options?: unknown) => ({
execute: vi.fn(async (command: string) => ({
stdout: command.includes('wc -l') ? '1\n' : command.includes("awk '{s+=$7}") ? '12\n' : '',
stderr: '',
exitCode: 0
......@@ -166,6 +171,7 @@ describe('sandbox archive service', () => {
archiveMocks.deleteWorkspaceArchive.mockResolvedValue(undefined);
archiveMocks.disconnectSandbox.mockResolvedValue(undefined);
archiveMocks.clearSandboxArchiveState.mockResolvedValue(undefined);
archiveMocks.clearSandboxRuntimeUpgradeArchiveState.mockResolvedValue(undefined);
archiveMocks.markSandboxArchived.mockResolvedValue({ matchedCount: 1, modifiedCount: 1 });
archiveMocks.isSandboxStillArchiving.mockResolvedValue(true);
archiveMocks.markSandboxRestored.mockImplementation(async (resource, params) => ({
......@@ -322,6 +328,44 @@ describe('sandbox archive service', () => {
expect(archiveMocks.markSandboxArchived).not.toHaveBeenCalled();
});
it('archives runtime upgrade resources without inactive checks', async () => {
const resource = createResource({
status: SandboxStatusEnum.running,
metadata: {
image: { repository: 'old-runtime' }
}
});
const archivingResource = {
...resource,
status: SandboxStatusEnum.stopped,
metadata: {
...resource.metadata,
archive: {
state: 'archiving'
}
}
};
const sandbox = createSandbox();
const remoteResource = { delete: vi.fn(async () => undefined), stop: vi.fn() };
archiveMocks.markSandboxArchivingForRuntimeUpgrade.mockResolvedValue(archivingResource);
archiveMocks.connectToSandbox.mockResolvedValue(sandbox);
archiveMocks.buildSandboxResourceAdapter.mockReturnValue(remoteResource);
const result = await archiveSandboxResourceForRuntimeUpgrade(resource, {
ensureZipInSandbox: true
});
expect(result).toEqual({ success: true });
expect(archiveMocks.markSandboxArchivingForRuntimeUpgrade).toHaveBeenCalledWith(resource);
expect(archiveMocks.isSandboxStillArchiving).not.toHaveBeenCalled();
expect(sandbox.execute).toHaveBeenCalledWith(expect.stringContaining('command -v zip'), {
timeoutMs: 600_000,
maxOutputBytes: 8 * 1024
});
expect(remoteResource.delete).toHaveBeenCalledTimes(1);
expect(archiveMocks.markSandboxArchived).toHaveBeenCalledWith(archivingResource);
});
it('restores archive from the current provider record before runtime use', async () => {
const archivedResource = createResource({
metadata: {
......
......@@ -163,9 +163,13 @@
"sandbox_confirm_delete_title": "Confirm delete",
"sandbox_connect_failed_max_attempts": "Too many failed attempts to connect to the sandbox. Connection stopped.",
"sandbox_create_failed": "Create failed",
"sandbox_copy_absolute_path": "Copy absolute path",
"sandbox_copy_path_failed": "Failed to copy path",
"sandbox_copy_path_success": "Path copied",
"sandbox_delete": "Delete",
"sandbox_delete_failed": "Delete failed",
"sandbox_download": "Download",
"sandbox_download_all": "Download all",
"sandbox_download_failed": "Download failed",
"sandbox_entry_tooltip": "View Sandbox files",
"sandbox_file_already_exists": "A file or folder with the same name already exists in the target directory",
......@@ -189,6 +193,7 @@
"sandbox_save_failed": "Save failed",
"sandbox_search_files": "Search files",
"sandbox_select_file_edit": "Select a file to edit",
"sandbox_selected_items_count": "{{count}} selected items",
"sandbox_starting": "Starting sandbox (1/2)",
"sandbox_status_checkExisting": "Checking sandbox...",
"sandbox_status_connecting": "Connecting to sandbox...",
......@@ -202,8 +207,14 @@
"sandbox_status_lazyInit": "Virtual machine is running...",
"sandbox_status_ready_cold": "Sandbox is ready",
"sandbox_status_ready_warm": "Sandbox is ready (warm start)",
"sandbox_status_runtimeUpgradeArchived": "Virtual machine archived. Rebuilding runtime...",
"sandbox_status_runtimeUpgradeArchiving": "Archiving outdated virtual machine...",
"sandbox_status_runtimeUpgradeRequired": "Virtual machine runtime needs an upgrade",
"sandbox_status_uploadingPackage": "Uploading skill package to sandbox...",
"sandbox_terminal": "Terminal",
"sandbox_unzip": "Unzip",
"sandbox_unzip_failed": "Unzip failed",
"sandbox_unzip_success": "Unzipped",
"sandbox_upload_failed": "Upload failed",
"sandbox_upload_file": "Upload file",
"sandbox_upload_too_large": "File too large",
......
......@@ -184,6 +184,7 @@
"code_error.skill_error.un_auth_skill": "Unauthorized to Operate This Skill",
"code_error.sandbox_error.agent_sandbox_initializing": "The virtual machine is initializing. Please try again later.",
"code_error.sandbox_error.agent_sandbox_permission_denied": "The current app is not authorized to use the sandbox/VM. Please contact an administrator to configure it.",
"code_error.sandbox_error.runtime_upgrade_failed": "Virtual machine runtime upgrade failed. Please try again.",
"code_error.system_error.community_version_num_limit": "Exceeded Open Source Version Limit, Please Upgrade to Commercial Version: https://fastgpt.io",
"code_error.system_error.license_app_amount_limit": "Exceed the maximum number of applications in the system",
"code_error.system_error.license_dataset_amount_limit": "Exceed the maximum number of knowledge bases in the system",
......
......@@ -63,6 +63,12 @@
"sandbox_uploading": "Uploading skill package to sandbox...",
"sandbox_extracting": "Extracting skill package...",
"sandbox_lazy_init": "Initializing runtime environment...",
"sandbox_runtime_upgrade_archived": "Outdated virtual machine is ready. Rebuilding runtime...",
"sandbox_runtime_upgrade_archiving": "Archiving outdated virtual machine...",
"sandbox_runtime_upgrade_confirm": "Upgrade now",
"sandbox_runtime_upgrade_desc": "A newer virtual machine runtime is available. Upgrade before continuing. The upgrade will not affect workspace files, but dependencies need to be reinstalled.",
"sandbox_runtime_upgrade_failed": "Upgrade failed. Please try again.",
"sandbox_runtime_upgrade_required": "Virtual machine runtime upgrade notice",
"sandbox_ready": "Sandbox is ready",
"sandbox_ready_warm": "Sandbox is ready (warm start)",
"sandbox_failed": "Sandbox creation failed: {{message}}",
......
......@@ -163,9 +163,13 @@
"sandbox_confirm_delete_title": "确认删除",
"sandbox_connect_failed_max_attempts": "连接沙盒失败次数过多,已停止尝试。",
"sandbox_create_failed": "新建失败",
"sandbox_copy_absolute_path": "复制绝对路径",
"sandbox_copy_path_failed": "复制路径失败",
"sandbox_copy_path_success": "路径已复制",
"sandbox_delete": "删除",
"sandbox_delete_failed": "删除失败",
"sandbox_download": "下载",
"sandbox_download_all": "下载全部",
"sandbox_download_failed": "下载失败",
"sandbox_entry_tooltip": "查看虚拟机文件",
"sandbox_file_already_exists": "目标目录中已存在同名文件或文件夹",
......@@ -189,6 +193,7 @@
"sandbox_save_failed": "保存文件失败",
"sandbox_search_files": "搜索文件",
"sandbox_select_file_edit": "选择一个文件进行编辑",
"sandbox_selected_items_count": "已选 {{count}} 项",
"sandbox_starting": "沙盒启动中(1/2)",
"sandbox_status_checkExisting": "正在检查沙箱环境...",
"sandbox_status_connecting": "正在连接沙箱环境...",
......@@ -202,8 +207,14 @@
"sandbox_status_lazyInit": "虚拟机运行中...",
"sandbox_status_ready_cold": "沙箱环境就绪",
"sandbox_status_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_status_runtimeUpgradeArchived": "虚拟机归档完成,正在重建运行环境...",
"sandbox_status_runtimeUpgradeArchiving": "正在归档旧版虚拟机...",
"sandbox_status_runtimeUpgradeRequired": "虚拟机运行环境需要升级",
"sandbox_status_uploadingPackage": "正在上传技能包到沙箱...",
"sandbox_terminal": "终端",
"sandbox_unzip": "解压",
"sandbox_unzip_failed": "解压失败",
"sandbox_unzip_success": "解压完成",
"sandbox_upload_failed": "上传失败",
"sandbox_upload_file": "上传文件",
"sandbox_upload_too_large": "文件过大",
......
......@@ -184,6 +184,7 @@
"code_error.skill_error.un_auth_skill": "无权操作该技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虚拟机正在初始化,请稍后重试。",
"code_error.sandbox_error.agent_sandbox_permission_denied": "当前应用无权使用虚拟机,请联系管理员配置。",
"code_error.sandbox_error.runtime_upgrade_failed": "虚拟机运行环境升级失败,请重新尝试。",
"code_error.system_error.community_version_num_limit": "超出社区版数量限制,请升级商业版: https://fastgpt.in",
"code_error.system_error.license_app_amount_limit": "超出系统最大应用数量",
"code_error.system_error.license_dataset_amount_limit": "超出系统最大知识库数量",
......
......@@ -63,6 +63,12 @@
"sandbox_uploading": "正在上传 Skill 包到沙箱...",
"sandbox_extracting": "正在解压 Skill 包...",
"sandbox_lazy_init": "正在初始化运行环境...",
"sandbox_runtime_upgrade_archived": "旧版虚拟机已处理完成,正在重建运行环境...",
"sandbox_runtime_upgrade_archiving": "正在归档旧版虚拟机...",
"sandbox_runtime_upgrade_confirm": "立即升级",
"sandbox_runtime_upgrade_desc": "检测到当前虚拟机环境有新版,需要升级后才能正常使用。升级版本不会影响工作区的文件,但需要重新安装依赖包。",
"sandbox_runtime_upgrade_failed": "升级失败,请重新尝试",
"sandbox_runtime_upgrade_required": "虚拟机环境升级提示",
"sandbox_ready": "沙箱环境就绪",
"sandbox_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_failed": "沙箱创建失败: {{message}}",
......
......@@ -160,9 +160,13 @@
"sandbox_confirm_delete_title": "確認刪除",
"sandbox_connect_failed_max_attempts": "連接沙箱失敗次數過多,已停止嘗試。",
"sandbox_create_failed": "新建失敗",
"sandbox_copy_absolute_path": "複製絕對路徑",
"sandbox_copy_path_failed": "複製路徑失敗",
"sandbox_copy_path_success": "路徑已複製",
"sandbox_delete": "刪除",
"sandbox_delete_failed": "刪除失敗",
"sandbox_download": "下載",
"sandbox_download_all": "下載全部",
"sandbox_download_failed": "下載失敗",
"sandbox_entry_tooltip": "查看虛擬機器文件",
"sandbox_file_already_exists": "目標目錄中已存在同名文件或資料夾",
......@@ -186,6 +190,7 @@
"sandbox_save_failed": "儲存文件失敗",
"sandbox_search_files": "搜尋文件",
"sandbox_select_file_edit": "選擇一個文件進行編輯",
"sandbox_selected_items_count": "已選 {{count}} 項",
"sandbox_starting": "沙盒啟動中(1/2)",
"sandbox_status_checkExisting": "正在檢查沙箱環境...",
"sandbox_status_connecting": "正在連接沙箱環境...",
......@@ -199,8 +204,14 @@
"sandbox_status_lazyInit": "虛擬機運行中...",
"sandbox_status_ready_cold": "沙箱環境就緒",
"sandbox_status_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_status_runtimeUpgradeArchived": "虛擬機歸檔完成,正在重建運行環境...",
"sandbox_status_runtimeUpgradeArchiving": "正在歸檔舊版虛擬機...",
"sandbox_status_runtimeUpgradeRequired": "虛擬機運行環境需要升級",
"sandbox_status_uploadingPackage": "正在上傳技能包到沙箱...",
"sandbox_terminal": "終端機",
"sandbox_unzip": "解壓",
"sandbox_unzip_failed": "解壓失敗",
"sandbox_unzip_success": "解壓完成",
"sandbox_upload_failed": "上傳失敗",
"sandbox_upload_file": "上傳文件",
"sandbox_upload_too_large": "文件過大",
......
......@@ -182,6 +182,7 @@
"code_error.skill_error.un_auth_skill": "無權操作該技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虛擬機正在初始化,請稍後重試。",
"code_error.sandbox_error.agent_sandbox_permission_denied": "當前應用無權使用虛擬機,請聯絡管理員配置。",
"code_error.sandbox_error.runtime_upgrade_failed": "虛擬機運行環境升級失敗,請重新嘗試。",
"code_error.system_error.community_version_num_limit": "超出開源版數量限制,請升級商業版:https://fastgpt.io",
"code_error.system_error.license_app_amount_limit": "超出系統最大應用數量",
"code_error.system_error.license_dataset_amount_limit": "超出系統最大知識庫數量",
......
......@@ -63,6 +63,12 @@
"sandbox_uploading": "正在上傳 Skill 包到沙箱...",
"sandbox_extracting": "正在解壓 Skill 包...",
"sandbox_lazy_init": "正在初始化運行環境...",
"sandbox_runtime_upgrade_archived": "舊版虛擬機已處理完成,正在重建運行環境...",
"sandbox_runtime_upgrade_archiving": "正在歸檔舊版虛擬機...",
"sandbox_runtime_upgrade_confirm": "立即升級",
"sandbox_runtime_upgrade_desc": "檢測到目前虛擬機環境有新版,需要升級後才能正常使用。升級版本不會影響工作區的檔案,但需要重新安裝依賴包。",
"sandbox_runtime_upgrade_failed": "升級失敗,請重新嘗試",
"sandbox_runtime_upgrade_required": "虛擬機環境升級提示",
"sandbox_ready": "沙箱環境就緒",
"sandbox_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_failed": "沙箱創建失敗: {{message}}",
......
......@@ -4,9 +4,22 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import { getDocPath } from '@/web/common/system/doc';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useTranslation } from 'next-i18next';
import type { ReactNode } from 'react';
import { useState } from 'react';
const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => {
type ProModalProps = {
isOpen?: boolean;
onClose?: () => void;
forceShow?: boolean;
title?: ReactNode;
content?: ReactNode;
primaryButtonText?: ReactNode;
primaryButtonLoading?: boolean;
onPrimaryClick?: () => void;
showSecondaryButton?: boolean;
};
const ProModal = (props: ProModalProps) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
......@@ -14,14 +27,26 @@ const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => {
const openModal = props?.isOpen ?? isOpen;
const onClose = props?.onClose ?? (() => setIsOpen(false));
const {
forceShow = false,
title = t('common:pro_modal_title'),
content,
primaryButtonText = t('common:pro_modal_unlock_button'),
primaryButtonLoading = false,
onPrimaryClick = () => {
window.open(getDocPath('/guide/version/commercial'), '_blank');
},
showSecondaryButton = true
} = props;
return feConfigs?.isPlus ? null : (
return feConfigs?.isPlus && !forceShow ? null : (
<MyModal
isOpen={openModal}
onClose={onClose}
showCloseButton={false}
w={'400px'}
minH={'392px'}
isCentered
>
<ModalBody
userSelect={'none'}
......@@ -52,50 +77,53 @@ const ProModal = (props: { isOpen?: boolean; onClose?: () => void }) => {
<MyIcon name={'star'} w={9} h={9} transform={'translateY(40%)'} />
</Flex>
<Box color={'myGray.900'} fontSize={'26px'} fontWeight={'bold'} lineHeight={'34px'}>
{t('common:pro_modal_title')}
{title}
</Box>
<VStack
w={'full'}
color={'myGray.900'}
fontSize={'18px'}
alignItems={'center'}
gap={0}
mt={7}
>
<Box lineHeight={'26px'}>{t('common:pro_modal_subtitle')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_1')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_2')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_3')}</Box>
<Box color={'myGray.500'} letterSpacing={'2px'} lineHeight={'26px'}>
......
</Box>
</VStack>
<Flex gap={3} flexDirection={'column'} w={'full'} mt={6}>
<Button
{content || (
<VStack
w={'full'}
h={'48px'}
borderRadius={'10px'}
onClick={() => {
window.open(getDocPath('/guide/version/commercial'), '_blank');
}}
fontSize={'16px'}
fontWeight={'medium'}
color={'myGray.900'}
fontSize={'18px'}
alignItems={'center'}
gap={0}
mt={7}
>
{t('common:pro_modal_unlock_button')}
</Button>
<Box lineHeight={'26px'}>{t('common:pro_modal_subtitle')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_1')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_2')}</Box>
<Box lineHeight={'26px'}>{t('common:pro_modal_feature_3')}</Box>
<Box color={'myGray.500'} letterSpacing={'2px'} lineHeight={'26px'}>
......
</Box>
</VStack>
)}
<Flex gap={3} flexDirection={'column'} w={'full'} mt={6}>
<Button
w={'full'}
h={'48px'}
borderRadius={'10px'}
variant={'whiteBase'}
onClick={onPrimaryClick}
isLoading={primaryButtonLoading}
fontSize={'16px'}
fontWeight={'medium'}
borderColor={'#E4E7ED'}
boxShadow={'0 2px 5px rgba(15, 23, 42, 0.06)'}
onClick={onClose}
>
{t('common:pro_modal_later_button')}
{primaryButtonText}
</Button>
{showSecondaryButton && (
<Button
w={'full'}
h={'48px'}
borderRadius={'10px'}
variant={'whiteBase'}
fontSize={'16px'}
fontWeight={'medium'}
borderColor={'#E4E7ED'}
boxShadow={'0 2px 5px rgba(15, 23, 42, 0.06)'}
onClick={onClose}
>
{t('common:pro_modal_later_button')}
</Button>
)}
</Flex>
</VStack>
</ModalBody>
......
......@@ -228,6 +228,9 @@ export const useChatGenerate = ({
downloadingPackage: t('chat:sandbox_status_downloadingPackage'),
uploadingPackage: t('chat:sandbox_status_uploadingPackage'),
extractingPackage: t('chat:sandbox_status_extractingPackage'),
runtimeUpgradeRequired: t('chat:sandbox_status_runtimeUpgradeRequired'),
runtimeUpgradeArchiving: t('chat:sandbox_status_runtimeUpgradeArchiving'),
runtimeUpgradeArchived: t('chat:sandbox_status_runtimeUpgradeArchived'),
lazyInit: t('chat:sandbox_status_lazyInit')
};
......
......@@ -42,6 +42,7 @@ function MemberItemCard({
justifyContent="space-between"
alignItems="center"
key={key}
minW={0}
px="1"
py="1"
gap="2"
......@@ -63,13 +64,14 @@ function MemberItemCard({
p="1"
alignItems={'center'}
gap="2"
w="full"
flex="1 1 0"
minW={0}
>
{isChecked !== undefined && (
<Checkbox isDisabled={disabled} isChecked={isChecked} pointerEvents="none" />
)}
<Avatar src={avatar} w="1.5rem" borderRadius={'50%'} />
<Box flex={'1 0 0'} w={0}>
<Avatar src={avatar} w="1.5rem" flexShrink={0} borderRadius={'50%'} />
<Box flex={'1 1 0'} minW={0}>
<Box fontSize={'sm'} w={'100%'} noOfLines={1}>
{name === DefaultGroupName ? userInfo?.team.teamName : name}
</Box>
......@@ -79,31 +81,40 @@ function MemberItemCard({
</Box>
</Flex>
{showRoleSelect && (
<RoleSelect
disabled={disabled}
value={role}
Button={
<Flex
bg={'myGray.50'}
border="base"
fontSize={'sm'}
borderRadius={'md'}
minH={'18px'}
w="300px"
p="1"
alignItems={'end'}
justifyContent={'space-between'}
>
<RoleTags permission={role} />
<Flex h="18px" alignItems={'center'} justifyContent={'center'}>
<ChevronDownIcon fontSize="md" />
<Box flex="0 1 300px" minW="160px" maxW="300px">
<RoleSelect
disabled={disabled}
value={role}
Button={
<Flex
bg={'myGray.50'}
border="base"
fontSize={'sm'}
borderRadius={'md'}
minH={'18px'}
w="full"
p="1"
alignItems={'end'}
justifyContent={'space-between'}
overflow="hidden"
>
<RoleTags permission={role} />
<Flex h="18px" flexShrink={0} alignItems={'center'} justifyContent={'center'}>
<ChevronDownIcon fontSize="md" />
</Flex>
</Flex>
</Flex>
}
onChange={onRoleChange}
/>
}
onChange={onRoleChange}
width="100%"
/>
</Box>
)}
<Flex flexDirection={'row'} h={showRoleSelect ? '36px' : 'unset'} alignItems={'center'}>
<Flex
flexDirection={'row'}
h={showRoleSelect ? '36px' : 'unset'}
flexShrink={0}
alignItems={'center'}
>
{onDelete !== undefined && !disabled ? (
<MyIcon
name="common/closeLight"
......
......@@ -63,7 +63,7 @@ function RoleSelect({
const roleOptions = useMemo(() => {
if (!permissionList) return { singleOptions: [], checkboxList: [] };
const list = Object.entries(permissionList).map(([_, value]) => {
const list = Object.values(permissionList).map((value) => {
return {
name: value.name,
value: value.value,
......@@ -104,6 +104,8 @@ function RoleSelect({
.map((item) => item.value);
}, [role, roleOptions.checkboxList]);
const menuMinWidth = typeof width === 'number' ? `${width}px !important` : width;
const onSelectRole = (newRole: RoleValueType) => {
if (newRole === role) return;
onChange(newRole);
......@@ -121,7 +123,7 @@ function RoleSelect({
<Menu offset={offset} isOpen={isOpen} autoSelect={false} direction={'ltr'}>
<Box
ref={ref}
w="fit-content"
w={width}
onMouseEnter={() => {
if (disabled) return;
if (trigger === 'hover') {
......@@ -139,6 +141,7 @@ function RoleSelect({
>
<MenuButton
position={'relative'}
w="full"
cursor={disabled ? 'not-allowed' : 'pointer'}
onClickCapture={() => {
if (trigger === 'click') {
......@@ -150,7 +153,7 @@ function RoleSelect({
{Button}
</MenuButton>
<MenuList
minW={isOpen ? `${width}px !important` : 0}
minW={isOpen ? menuMinWidth : 0}
p="3"
border={'1px solid #fff'}
boxShadow={
......
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Center, VStack, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { Box, Flex } from '@chakra-ui/react';
import { SkillDetailContext } from '../../dashboard/skill/detail/context';
import { useContextSelector } from 'use-context-selector';
import MyBox from '@fastgpt/web/components/common/MyBox';
import type Editor from '@monaco-editor/react';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import FileTree from './components/FileTree';
......@@ -27,9 +24,13 @@ export type Props = {
outLinkAuthData?: OutLinkChatAuthProps;
showFileOps?: boolean;
showDownload?: boolean;
showFileTreeDownload?: boolean;
defaultViewMode?: 'source' | 'preview';
isPreparing?: boolean;
showTerminal?: boolean;
enablePathCopy?: boolean;
enableZipExtract?: boolean;
enableMultiSelect?: boolean;
onError?: (err: Error) => void;
headerRight?: React.ReactNode;
bg?: string;
......@@ -42,14 +43,17 @@ const SandboxEditor = ({
outLinkAuthData,
showFileOps = true,
showDownload = true,
showFileTreeDownload,
defaultViewMode,
isPreparing = false,
showTerminal = false,
enablePathCopy = false,
enableZipExtract = false,
enableMultiSelect = false,
onError,
headerRight,
bg
}: Props) => {
const { t } = useTranslation();
const saveAllRef = useContextSelector(SkillDetailContext, (v) => v.saveAllRef);
const editorRef = useRef<SandboxEditorInstance>();
const editorLayoutRef = useRef<HTMLDivElement>(null);
......@@ -59,6 +63,7 @@ const SandboxEditor = ({
() => resolveSandboxTarget({ appId, chatTarget }),
[appId, chatTarget?.appId, chatTarget?.skillId]
);
const fileTreeShowDownload = showFileTreeDownload ?? showDownload;
const {
fileTree,
......@@ -78,6 +83,7 @@ const SandboxEditor = ({
searchQuery,
setSearchQuery,
activeFile,
refreshWorkspace,
openFile,
closeFile,
saveFile,
......@@ -85,9 +91,10 @@ const SandboxEditor = ({
downloadCurrentFile,
onCreateNode,
onRenameComplete,
onMoveFile,
onMoveFiles,
onDeleteFile,
onUploadFiles,
onExecCommand,
toggleDirectory
} = useSandboxFileStore({
sandboxTarget,
......@@ -185,14 +192,20 @@ const SandboxEditor = ({
toggleDirectory={toggleDirectory}
onCreateNode={onCreateNode}
onRenameComplete={onRenameComplete}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDeleteFile={onDeleteFile}
onUploadFiles={onUploadFiles}
onExecCommand={onExecCommand}
onRefreshWorkspace={refreshWorkspace}
setExpandedDirs={setExpandedDirs}
sandboxTarget={sandboxTarget}
chatId={chatId}
outLinkAuthData={outLinkAuthData}
showFileOps={showFileOps}
showDownload={fileTreeShowDownload}
enablePathCopy={enablePathCopy}
enableZipExtract={enableZipExtract}
enableMultiSelect={enableMultiSelect}
isLoading={isInitialLoading}
/>
);
......
......@@ -12,7 +12,9 @@ type Props = {
loadingDirs: Set<string>;
activeFilePath: string;
selectedPath: string;
setSelectedPath: (path: string) => void;
selectedPaths: Set<string>;
selectSingleNode: (path: string) => void;
toggleSelectedNode: (path: string) => void;
realOverDestPath: string | null;
openFile: (path: string) => void;
toggleDirectory: (node: TreeNode) => void;
......@@ -25,6 +27,7 @@ type Props = {
onConfirmCreate: (name: string) => void;
onCancelCreate: () => void;
showFileOps?: boolean;
enableMultiSelect?: boolean;
};
const FileTreeNode = ({
......@@ -33,7 +36,9 @@ const FileTreeNode = ({
loadingDirs,
activeFilePath,
selectedPath,
setSelectedPath,
selectedPaths,
selectSingleNode,
toggleSelectedNode,
realOverDestPath,
openFile,
toggleDirectory,
......@@ -45,12 +50,14 @@ const FileTreeNode = ({
renderTreeNodes,
onConfirmCreate,
onCancelCreate,
showFileOps = true
showFileOps = true,
enableMultiSelect = false
}: Props) => {
const isExpanded = expandedDirs.has(node.path);
const isLoading = loadingDirs.has(node.path);
const isActive = node.type === 'file' && activeFilePath === node.path;
const isSelected = selectedPath === node.path;
const shouldShowActiveBg = isActive && !enableMultiSelect;
const isSelected = enableMultiSelect ? selectedPaths.has(node.path) : selectedPath === node.path;
const isRenaming = renamingPath === node.path;
const shouldShowArrow = node.type === 'directory';
......@@ -75,6 +82,14 @@ const FileTreeNode = ({
// 完美VSCode拖放体验:只允许文件夹节点和根目录Box显示放置高亮
const isOverNode = node.type === 'directory' && (isOver || realOverDestPath === node.path);
const nodeBg = isOverNode
? 'rgba(56, 139, 253, 0.08)'
: isSelected
? 'primary.100'
: shouldShowActiveBg
? 'myGray.05'
: 'transparent';
const hoverBg = isOverNode || isSelected ? nodeBg : 'myGray.05';
return (
<Box>
......@@ -88,22 +103,20 @@ const FileTreeNode = ({
h="28px"
cursor="pointer"
opacity={isDragging ? 0.4 : 1}
_hover={{ bg: 'myGray.05' }}
bg={
isOverNode
? 'rgba(56, 139, 253, 0.08)'
: isSelected
? 'primary.100'
: isActive
? 'myGray.05'
: 'transparent'
}
_hover={{ bg: hoverBg }}
bg={nodeBg}
border={isOverNode ? '1px dashed #2B5FD9' : '1px solid transparent'}
borderRadius="xs"
onClick={() => {
onClick={(e) => {
if (!node || !node.path) return;
if (enableMultiSelect && (e.ctrlKey || e.metaKey)) {
toggleSelectedNode(node.path);
return;
}
selectSingleNode(node.path);
if (node.type === 'file') {
setSelectedPath(node.path);
openFile(node.path);
} else {
toggleDirectory(node);
......
......@@ -18,6 +18,7 @@ import type { TreeNode } from './components/FileTree';
import type { OpenedFile } from './components/FileTabs';
import type { ChatTargetInputType } from '@fastgpt/global/openapi/core/chat/api';
import { getSandboxTargetId, resolveSandboxTarget } from './types';
import type { ExecuteResult } from '@fastgpt-sdk/sandbox-adapter';
import {
getLanguageByFileName,
getIsBinaryByLanguage,
......@@ -27,7 +28,13 @@ import {
renameTreeNodeInTree,
findNodeByPath,
sortTreeNodes,
updateTreeNode
updateTreeNode,
replacePathPrefix,
getSandboxPathName,
getSandboxParentPath,
joinSandboxPath,
applySandboxMoveOperationsToExpandedDirs,
type SandboxMoveOperation
} from './utils';
const SYSTEM_FILE_NAMES = ['.DS_Store'];
......@@ -73,12 +80,6 @@ const encodeBase64 = (content: string) => {
const isValidPathSegment = (name: string) =>
!!name && name !== '.' && name !== '..' && !INVALID_PATH_SEGMENT_CHARS.test(name);
const replacePathPrefix = (path: string, oldPath: string, newPath: string) => {
if (path === oldPath) return newPath;
if (path.startsWith(oldPath + '/')) return `${newPath}${path.slice(oldPath.length)}`;
return path;
};
/**
* useSandboxEditor —— UI Hook
*
......@@ -408,7 +409,11 @@ export const useSandboxFileStore = ({
// RPC 调用
const rpcCall = useCallback(
async <T = unknown,>(method: string, params: unknown): Promise<T> => {
async <T = unknown,>(
method: string,
params: unknown,
options: { timeoutMs?: number } = {}
): Promise<T> => {
if (stoppedConnectErrorRef.current) {
throw stoppedConnectErrorRef.current;
}
......@@ -426,7 +431,7 @@ export const useSandboxFileStore = ({
const timer = window.setTimeout(() => {
pendingRpcRequestsRef.current.delete(id);
reject(new Error(`Sandbox RPC timeout: ${method}`));
}, RPC_TIMEOUT_MS);
}, options.timeoutMs ?? RPC_TIMEOUT_MS);
pendingRpcRequestsRef.current.set(id, {
resolve: (res) => {
......@@ -1228,10 +1233,8 @@ export const useSandboxFileStore = ({
}
const oldName = oldPath.split('/').pop() || '';
const parts = oldPath.split('/');
parts.pop();
const parentPath = parts.join('/');
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
const parentPath = getSandboxParentPath(oldPath);
const newPath = joinSandboxPath(parentPath, newName);
if (oldPath === newPath) return;
......@@ -1271,25 +1274,34 @@ export const useSandboxFileStore = ({
);
// 移动文件/目录(拖拽移动) (乐观更新)
const onMoveFile = useCallback(
async (srcPath: string, targetDirPath: string) => {
const parts = srcPath.split('/');
const fileName = parts.pop() || '';
const srcParentPath = parts.join('/') || '.';
const destPath = targetDirPath === '.' ? fileName : `${targetDirPath}/${fileName}`;
if (srcPath === destPath) return;
const onMoveFiles = useCallback(
async (operations: SandboxMoveOperation[], options?: { expandPath?: string | null }) => {
if (operations.length === 0) return [];
// 1. 乐观更新
updateStatePaths(srcPath, destPath);
setFileTree((prevTree) => moveTreeNodeInTree(prevTree, srcPath, targetDirPath));
operations.forEach((item) => {
updateStatePaths(item.sourcePath, item.newPath);
});
setExpandedDirs((prev) =>
applySandboxMoveOperationsToExpandedDirs(prev, operations, options?.expandPath)
);
setFileTree((prevTree) =>
operations.reduce(
(tree, item) => moveTreeNodeInTree(tree, item.sourcePath, item.targetDirPath),
prevTree
)
);
// 2. 异步请求,失败时回滚
const movedItems: SandboxMoveOperation[] = [];
try {
await rpcCall('fs/move', {
from: srcPath,
to: destPath
});
for (const item of operations) {
await rpcCall('fs/move', {
from: item.sourcePath,
to: item.newPath
});
movedItems.push(item);
}
return movedItems;
} catch (error) {
console.error('Failed to move file:', error);
toast({
......@@ -1297,14 +1309,64 @@ export const useSandboxFileStore = ({
description: getErrText(error),
status: 'error'
});
updateStatePaths(destPath, srcPath);
setFileTree((prevTree) => moveTreeNodeInTree(prevTree, destPath, srcParentPath));
const movedPathSet = new Set(movedItems.map((item) => item.newPath));
const rollbackOperations = operations
.filter((item) => !movedPathSet.has(item.newPath))
.map((item) => ({
sourcePath: item.newPath,
currentParentPath: item.targetDirPath,
targetDirPath: item.currentParentPath,
newPath: item.sourcePath
}));
operations
.slice()
.reverse()
.forEach((item) => {
if (!movedPathSet.has(item.newPath)) {
updateStatePaths(item.newPath, item.sourcePath);
}
});
setExpandedDirs((prev) =>
applySandboxMoveOperationsToExpandedDirs(prev, rollbackOperations.reverse())
);
setFileTree((prevTree) =>
operations
.slice()
.reverse()
.reduce((tree, item) => {
return movedPathSet.has(item.newPath)
? tree
: moveTreeNodeInTree(tree, item.newPath, item.currentParentPath);
}, prevTree)
);
await refreshWorkspace({ preserveExpandedDirs: true });
throw Object.assign(error instanceof Error ? error : new Error(getErrText(error)), {
movedItems
});
}
},
[rpcCall, toast, t, refreshWorkspace]
);
const onMoveFile = useCallback(
async (srcPath: string, targetDirPath: string) => {
const fileName = getSandboxPathName(srcPath);
const destPath = joinSandboxPath(targetDirPath, fileName);
if (srcPath === destPath) return;
await onMoveFiles([
{
sourcePath: srcPath,
currentParentPath: getSandboxParentPath(srcPath),
targetDirPath,
newPath: destPath
}
]);
},
[onMoveFiles]
);
// 删除文件/目录
const onDeleteFile = useCallback(
async (filePath: string) => {
......@@ -1319,11 +1381,26 @@ export const useSandboxFileStore = ({
description: getErrText(error),
status: 'error'
});
throw error;
}
},
[rpcCall, toast, t]
);
const onExecCommand = useCallback(
async (command: string, timeoutMs?: number) => {
const execTimeoutMs = timeoutMs ?? RPC_TIMEOUT_MS;
return rpcCall<ExecuteResult>(
'fs/exec',
{ command, timeoutMs },
{
timeoutMs: execTimeoutMs + 1000
}
);
},
[rpcCall]
);
// 上传文件
const onUploadFiles = useCallback(
async (files: FileList, targetDirPath: string) => {
......@@ -1489,8 +1566,10 @@ export const useSandboxFileStore = ({
onCreateNode,
onRenameComplete,
onMoveFile,
onMoveFiles,
onDeleteFile,
onUploadFiles,
onExecCommand,
toggleDirectory
};
};
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type';
export const getSafeSandboxCommandPath = (path: string) => {
const normalizedPath = path.replace(/^\.\//, '');
const segments = normalizedPath.split('/');
if (
path.startsWith('/') ||
segments.some((segment) => segment === '..') ||
normalizedPath.includes('\u0000')
) {
throw new Error('Invalid sandbox path');
}
return normalizedPath === '.' ? '.' : `./${normalizedPath}`;
};
export const getSafeSandboxPathSegment = (name: string) => {
if (!name || name === '.' || name === '..' || /[\/\\\u0000]/.test(name)) {
throw new Error('Invalid sandbox path');
}
return name;
};
export const getSandboxParentPath = (path: string) => {
const parts = path.split('/');
parts.pop();
return parts.join('/') || '.';
};
export const getSandboxPathName = (path: string) => {
const parts = path.split('/');
return parts.pop() || '';
};
export const joinSandboxPath = (parentPath: string, name: string) =>
parentPath === '.' ? name : `${parentPath}/${name}`;
/** 将 oldPath 及其子路径替换为 newPath,用于移动/重命名后的状态同步。 */
export const replacePathPrefix = (path: string, oldPath: string, newPath: string) => {
if (path === oldPath) return newPath;
if (path.startsWith(oldPath + '/')) return `${newPath}${path.slice(oldPath.length)}`;
return path;
};
/** 父子节点同时被选择时,仅保留父节点,避免重复操作同一棵子树。 */
export const getTopLevelSandboxPaths = (paths: string[]) => {
const uniquePaths = Array.from(new Set(paths)).filter((path) => path && path !== '.');
const result: string[] = [];
uniquePaths
.sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b))
.forEach((path) => {
if (!result.some((parentPath) => path.startsWith(parentPath + '/'))) {
result.push(path);
}
});
return result;
};
export type SandboxMoveOperation = {
sourcePath: string;
currentParentPath: string;
targetDirPath: string;
newPath: string;
};
export const buildSandboxMoveOperations = (
sourcePaths: string[],
targetDirPath: string
): SandboxMoveOperation[] => {
return sourcePaths
.map((sourcePath) => {
const currentParentPath = getSandboxParentPath(sourcePath);
const sourceName = getSandboxPathName(sourcePath);
const newPath = joinSandboxPath(targetDirPath, sourceName);
return {
sourcePath,
currentParentPath,
targetDirPath,
newPath
};
})
.filter((item) => item.currentParentPath !== targetDirPath);
};
export const applySandboxMoveOperationsToExpandedDirs = (
expandedDirs: Set<string>,
operations: SandboxMoveOperation[],
expandPath?: string | null
) => {
let next = new Set(expandedDirs);
operations.forEach((item) => {
const updated = new Set<string>();
next.forEach((path) => {
updated.add(replacePathPrefix(path, item.sourcePath, item.newPath));
});
next = updated;
});
if (expandPath && expandPath !== '.') {
next.add(expandPath);
}
return next;
};
export const getTargetDirectoryPath = <
T extends { type: 'file' | 'directory'; path: string; children?: T[] }
>(
tree: T[],
selectedPath: string
) => {
if (!selectedPath || selectedPath === '.') return '.';
const selectedNode = findNodeByPath(tree, selectedPath);
if (selectedNode) {
return selectedNode.type === 'directory' ? selectedPath : getSandboxParentPath(selectedPath);
}
const looksLikeFile = selectedPath.includes('.') && !selectedPath.startsWith('.');
return looksLikeFile ? getSandboxParentPath(selectedPath) : selectedPath;
};
// Get icon by filename
export const getIconByFilename = (filename: string): IconNameType => {
const ext = filename.split('.').pop()?.toLowerCase();
......
import React from 'react';
import { Box } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector';
import { useTranslation } from 'next-i18next';
import { SkillDetailContext } from './context';
import SandboxEditor from '@/pageComponents/chat/SandboxEditor/Editor';
import SandboxError from './config/SandboxError';
import { RightHeader } from '@/pageComponents/dashboard/skill/detail/Header';
import ProModal from '@/components/ProTip/ProModal';
const EDIT_DEBUG_CHAT_ID = 'edit-debug';
const Content = () => {
const { sandboxState, skillId, isSkillReady, handleSandboxError } = useContextSelector(
SkillDetailContext,
(v) => ({
const { t } = useTranslation();
const { sandboxState, skillId, isSkillReady, handleSandboxError, upgradeSandboxRuntime } =
useContextSelector(SkillDetailContext, (v) => ({
sandboxState: v.sandboxState,
skillId: v.skillId,
isSkillReady: v.isSkillReady,
handleSandboxError: v.handleSandboxError
})
);
handleSandboxError: v.handleSandboxError,
upgradeSandboxRuntime: v.upgradeSandboxRuntime
}));
const isSandboxReady = sandboxState === 'ready';
const isUpgrading = sandboxState === 'upgrading';
const isUpgradeModalOpen = sandboxState === 'upgradeRequired' || isUpgrading;
const canOperateSandbox = isSkillReady && isSandboxReady;
return (
......@@ -41,13 +45,31 @@ const Content = () => {
chatId={EDIT_DEBUG_CHAT_ID}
showFileOps={true}
showDownload={false}
showFileTreeDownload={true}
defaultViewMode={'source'}
isPreparing={!isSandboxReady}
showTerminal={true}
enablePathCopy={true}
enableZipExtract={true}
enableMultiSelect={true}
onError={(err) => handleSandboxError(err.message)}
headerRight={canOperateSandbox ? <RightHeader /> : undefined}
/>
)}
<ProModal
isOpen={isUpgradeModalOpen}
forceShow
title={t('skill:sandbox_runtime_upgrade_required')}
content={
<Box color={'myGray.900'} fontSize={'18px'} lineHeight={'26px'} mt={7}>
{t('skill:sandbox_runtime_upgrade_desc')}
</Box>
}
primaryButtonText={t('skill:sandbox_runtime_upgrade_confirm')}
primaryButtonLoading={isUpgrading}
onPrimaryClick={upgradeSandboxRuntime}
showSecondaryButton={false}
/>
</Box>
);
};
......
......@@ -9,6 +9,7 @@ import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/cons
import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type';
import { isValidObjectId } from 'mongoose';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
import { UserError } from '@fastgpt/global/common/error/utils';
import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
import { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/ai/skill/constants';
import {
......@@ -36,7 +37,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.flushHeaders();
try {
const { skillId, image } = parseApiInput({
const { skillId, image, archiveForUpgrade } = parseApiInput({
req,
bodySchema: CreateEditDebugSandboxBodySchema
}).body;
......@@ -84,6 +85,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
teamId,
tmbId,
image,
archiveForUpgrade,
onProgress
});
......@@ -93,7 +95,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// 请求参数错误是 API 边界可预期错误;运行时异常仍统一隐藏实现细节。
sseErrRes(
res,
getZodParseErrorInputSource(error) ? error : new Error('Failed to create sandbox')
getZodParseErrorInputSource(error) || error instanceof UserError
? error
: new Error('Failed to create sandbox')
);
res.end();
}
......
......@@ -226,7 +226,7 @@ export default ContextRender;
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['app', 'common', 'file', 'skill']))
...(await serviceSideProps(content, ['app', 'common', 'file', 'skill', 'user']))
}
};
}
......@@ -66,7 +66,7 @@ export default SkillDetail;
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['app', 'chat', 'common', 'skill']))
...(await serviceSideProps(content, ['app', 'chat', 'common', 'skill', 'user']))
}
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment