V4.14.9 dev (#6555)

* feat: encapsulate logger (#6535)

* feat: encapsulate logger

* update engines

---------

Co-authored-by: archer <545436317@qq.com>

* next config

* dev shell

* Agent sandbox (#6532)

* docs: switch to docs layout and apply black theme (#6533)

* feat: add Gemini 3.1 models

- Add gemini-3.1-pro-preview (released February 19, 2026)
- Add gemini-3.1-flash-lite-preview (released March 3, 2026)

Both models support:
- 1M context window
- 64k max response
- Vision
- Tool choice

* docs: switch to docs layout and apply black theme

- Change layout from notebook to docs
- Update logo to icon + text format
- Apply fumadocs black theme
- Simplify global.css (keep only navbar and TOC styles)
- Fix icon components to properly accept className props
- Add mobile text overflow handling
- Update Node engine requirement to >=20.x

* doc

* doc

* lock

* fix: ts

* doc

* doc

---------

Co-authored-by: archer <archer@archerdeMac-mini.local>
Co-authored-by: archer <545436317@qq.com>

* Doc (#6493)

* cloud doc

* doc refactor

* doc move

* seo

* remove doc

* yml

* doc

* fix: tsconfig

* fix: tsconfig

* sandbox version (#6497)

* sandbox version

* add sandbox log

* update lock

* fix

* fix: sandbox

* doc

* add console

* i18n

* sandbxo in agent

* feat: agent sandbox

* lock

* feat: sandbox ui

* sandbox check exists

* env tempalte

* doc

* lock

* sandbox in chat window

* sandbox entry

* fix: test

* rename var

* sandbox config tip

* update sandbox lifecircle

* update prompt

* rename provider test

* sandbox logger

* yml

---------

Co-authored-by: Archer <archer@fastgpt.io>
Co-authored-by: archer <archer@archerdeMac-mini.local>

* perf: sandbox error tip

* Add sandbox limit and fix some issue (#6550)

* sandbox in plan

* fix: some issue

* fix: test

* editor default path

* fix: comment

* perf: sandbox worksapce

* doc

* perf: del sandbox

* sandbox build

* fix: test

* fix: pr comment

---------

Co-authored-by: Ryo <whoeverimf5@gmail.com>
Co-authored-by: Archer <archer@fastgpt.io>
Co-authored-by: archer <archer@archerdeMac-mini.local>
This commit is contained in:
Archer
2026-03-16 17:09:25 +08:00
committed by GitHub
parent 21b3f8549a
commit aaa7d17ef1
258 changed files with 6844 additions and 6162 deletions
+10 -1
View File
@@ -48,7 +48,16 @@ vi.mock('@fastgpt/service/core/ai/llm/promptCall', () => ({
}));
vi.mock('@fastgpt/global/core/ai/llm/utils', () => ({
removeDatasetCiteText: vi.fn((text: string) => text)
removeDatasetCiteText: vi.fn((text: string) => text),
getLLMSupportParams: vi.fn(() => ({
vision: false,
temperature: true,
reasoning: false,
topP: true,
stop: true,
responseFormat: false,
supportToolCall: true
}))
}));
vi.mock('@fastgpt/service/core/ai/utils', () => ({
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { generateSandboxId } from '@fastgpt/global/core/ai/sandbox/constants';
describe('generateSandboxId', () => {
it('should return same ID for same triplet', () => {
const id1 = generateSandboxId('app1', 'user1', 'chat1');
const id2 = generateSandboxId('app1', 'user1', 'chat1');
expect(id1).toBe(id2);
});
it('should return different IDs for different triplets', () => {
const id1 = generateSandboxId('app1', 'user1', 'chat1');
const id2 = generateSandboxId('app1', 'user1', 'chat2');
const id3 = generateSandboxId('app2', 'user1', 'chat1');
expect(id1).not.toBe(id2);
expect(id1).not.toBe(id3);
expect(id2).not.toBe(id3);
});
it('should return a 16-char hex string', () => {
const id = generateSandboxId('app1', 'user1', 'chat1');
expect(id).toHaveLength(16);
expect(id).toMatch(/^[0-9a-f]{16}$/);
});
});
@@ -0,0 +1,228 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest';
// Mock the env module BEFORE any imports that use it
vi.mock('@fastgpt/service/env', () => ({
env: {
AGENT_SANDBOX_PROVIDER: 'sealosdevbox',
AGENT_SANDBOX_SEALOS_BASEURL: 'http://mock-sandbox.local',
AGENT_SANDBOX_SEALOS_TOKEN: 'mock-token-12345'
}
}));
// Mock the SealosDevboxAdapter to avoid real API calls
vi.mock('@fastgpt-sdk/sandbox-adapter', () => {
class MockSealosDevboxAdapter {
async create() {
return undefined;
}
async start() {
return undefined;
}
async stop() {
return undefined;
}
async delete() {
return undefined;
}
async getInfo() {
return null;
}
async execute() {
return { stdout: 'ok', stderr: '', exitCode: 0 };
}
async waitUntilReady() {
return undefined;
}
async ensureRunning() {
return undefined;
}
}
return {
SealosDevboxAdapter: MockSealosDevboxAdapter
};
});
import { connectionMongo } from '@fastgpt/service/common/mongo';
import { MongoSandboxInstance } from '@fastgpt/service/core/ai/sandbox/schema';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import {
deleteSandboxesByChatIds,
deleteSandboxesByAppId
} from '@fastgpt/service/core/ai/sandbox/controller';
const { Types } = connectionMongo;
const oid = () => String(new Types.ObjectId());
beforeAll(async () => {
vi.clearAllMocks();
await MongoSandboxInstance.deleteMany({});
});
const appId1 = oid();
const appId2 = oid();
describe('deleteSandboxesByChatIds', () => {
beforeEach(async () => {
await MongoSandboxInstance.create([
{
provider: 'sealosdevbox',
sandboxId: 'sb1',
appId: appId1,
userId: 'u1',
chatId: 'c1',
status: 'running',
lastActiveAt: new Date(),
createdAt: new Date()
},
{
provider: 'sealosdevbox',
sandboxId: 'sb2',
appId: appId1,
userId: 'u1',
chatId: 'c2',
status: 'running',
lastActiveAt: new Date(),
createdAt: new Date()
},
{
provider: 'sealosdevbox',
sandboxId: 'sb3',
appId: appId2,
userId: 'u1',
chatId: 'c3',
status: 'running',
lastActiveAt: new Date(),
createdAt: new Date()
}
]);
});
it('should call delete for specified chatIds', async () => {
const countBefore = await MongoSandboxInstance.countDocuments({ appId: appId1 });
expect(countBefore).toBe(2);
await deleteSandboxesByChatIds({ appId: appId1, chatIds: ['c1', 'c2'] });
// 验证不影响其他 appId 的数据
expect(await MongoSandboxInstance.countDocuments({ appId: appId2 })).toBe(1);
});
it('should not error when chatId does not exist', async () => {
await expect(
deleteSandboxesByChatIds({ appId: appId1, chatIds: ['nonexistent'] })
).resolves.not.toThrow();
});
it('should handle empty chatIds array', async () => {
await expect(deleteSandboxesByChatIds({ appId: appId1, chatIds: [] })).resolves.not.toThrow();
});
});
describe('deleteSandboxesByAppId', () => {
beforeEach(async () => {
await MongoSandboxInstance.create([
{
provider: 'sealosdevbox',
sandboxId: 'sb1',
appId: appId1,
userId: 'u1',
chatId: 'c1',
status: 'running',
lastActiveAt: new Date(),
createdAt: new Date()
},
{
provider: 'sealosdevbox',
sandboxId: 'sb2',
appId: appId1,
userId: 'u1',
chatId: 'c2',
status: 'stoped',
lastActiveAt: new Date(),
createdAt: new Date()
},
{
provider: 'sealosdevbox',
sandboxId: 'sb3',
appId: appId2,
userId: 'u1',
chatId: 'c3',
status: 'running',
lastActiveAt: new Date(),
createdAt: new Date()
}
]);
});
it('should call delete for all sandboxes under appId', async () => {
const countBefore = await MongoSandboxInstance.countDocuments({ appId: appId1 });
expect(countBefore).toBe(2);
await deleteSandboxesByAppId(appId1);
// 验证不影响其他 appId 的数据
expect(await MongoSandboxInstance.countDocuments({ appId: appId2 })).toBe(1);
});
it('should not error when appId has no sandboxes', async () => {
const emptyAppId = oid();
await expect(deleteSandboxesByAppId(emptyAppId)).resolves.not.toThrow();
});
});
describe('cronJob - suspendInactiveSandboxes', () => {
it('should identify running sandboxes inactive > 5 min', async () => {
const old = new Date(Date.now() - 10 * 60 * 1000);
const recent = new Date();
await MongoSandboxInstance.create([
{
provider: 'sealosdevbox',
sandboxId: 'old1',
appId: appId1,
userId: 'u',
chatId: 'c1',
status: 'running',
lastActiveAt: old,
createdAt: old
},
{
provider: 'sealosdevbox',
sandboxId: 'recent1',
appId: appId1,
userId: 'u',
chatId: 'c2',
status: 'running',
lastActiveAt: recent,
createdAt: recent
},
{
provider: 'sealosdevbox',
sandboxId: 'already',
appId: appId1,
userId: 'u',
chatId: 'c3',
status: 'stoped',
lastActiveAt: old,
createdAt: old
}
]);
// 模拟定时任务的查询逻辑
const instances = await MongoSandboxInstance.find({
status: SandboxStatusEnum.running,
lastActiveAt: { $lt: new Date(Date.now() - 5 * 60 * 1000) }
}).lean();
// 验证查询逻辑正确:只找到超过 5 分钟未活动的 running 状态沙盒
expect(instances).toHaveLength(1);
expect(instances[0].sandboxId).toBe('old1');
// 验证不包含最近活动的沙盒
expect(instances.find((i) => i.sandboxId === 'recent1')).toBeUndefined();
// 验证不包含已停止的沙盒
expect(instances.find((i) => i.sandboxId === 'already')).toBeUndefined();
});
});
@@ -0,0 +1,314 @@
import { describe, it, expect, afterAll, beforeAll, vi } from 'vitest';
import { MongoSandboxInstance } from '@fastgpt/service/core/ai/sandbox/schema';
import {
SandboxClient,
deleteSandboxesByChatIds,
deleteSandboxesByAppId
} from '@fastgpt/service/core/ai/sandbox/controller';
import { connectionMongo } from '@fastgpt/service/common/mongo';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { delay } from '@fastgpt/global/common/system/utils';
const { Types } = connectionMongo;
const hasSandboxEnv = !!(
process.env.AGENT_SANDBOX_PROVIDER &&
process.env.AGENT_SANDBOX_SEALOS_BASEURL &&
process.env.AGENT_SANDBOX_SEALOS_TOKEN
);
vi.mock('@fastgpt/service/env', () => ({
env: {
AGENT_SANDBOX_PROVIDER: process.env.AGENT_SANDBOX_PROVIDER,
AGENT_SANDBOX_SEALOS_BASEURL: process.env.AGENT_SANDBOX_SEALOS_BASEURL,
AGENT_SANDBOX_SEALOS_TOKEN: process.env.AGENT_SANDBOX_SEALOS_TOKEN
}
}));
describe.skipIf(!hasSandboxEnv).sequential('Sandbox Integration', () => {
const testDir = '/tmp/workspace';
const testParams = {
appId: String(new Types.ObjectId()),
userId: 'integration-user',
chatId: `integration-chat-${Date.now()}`
};
let sandbox: SandboxClient;
// 测试开始前,确认 workspace 存在
beforeAll(async () => {
sandbox = new SandboxClient(testParams);
const result = await sandbox.exec(`mkdir -p ${testDir} && cd ${testDir}`);
expect(result.exitCode).toBe(0);
await delay(2000);
});
afterAll(async () => {
// 清理测试创建的沙盒实例
try {
await sandbox.delete();
} catch (error) {
console.warn('Failed to cleanup sandbox:', error);
}
});
it('should create sandbox and execute echo command', async () => {
const result = await sandbox.exec('echo hello');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('hello');
});
it('should return non-zero exitCode for failing command', async () => {
const result = await sandbox.exec('exit 1');
expect(result.exitCode).not.toBe(0);
});
it('should share filesystem within same session', async () => {
await sandbox.exec(`touch ${testDir}/test-integration.txt`);
const result = await sandbox.exec(`ls ${testDir}/test-integration.txt`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('test-integration.txt');
});
it('should delete sandbox and clean DB on deleteSandboxesByChatIds', async () => {
await sandbox.exec('echo setup');
await deleteSandboxesByChatIds({ appId: testParams.appId, chatIds: [testParams.chatId] });
const count = await MongoSandboxInstance.countDocuments({ chatId: testParams.chatId });
expect(count).toBe(0);
});
// ===== 错误处理和边界情况 =====
describe('Error Handling', () => {
it('should handle command timeout gracefully', async () => {
// 超时会抛出异常而不是返回错误码
await expect(sandbox.exec('sleep 3', 1)).rejects.toThrow();
});
it('should handle invalid commands', async () => {
const result = await sandbox.exec('nonexistent-command-xyz');
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toBeTruthy();
});
it('should handle empty command', async () => {
// 空命令在某些沙盒实现中可能失败,改为测试 true 命令
const result = await sandbox.exec('true');
expect(result.exitCode).toBe(0);
});
it('should handle very long output', async () => {
const result = await sandbox.exec('seq 1 10000');
expect(result.exitCode).toBe(0);
expect(result.stdout.length).toBeGreaterThan(0);
});
});
// ===== 状态管理测试 =====
describe('State Management', () => {
it('should update status to running after exec', async () => {
await sandbox.exec('echo test');
const doc = await MongoSandboxInstance.findOne({ chatId: testParams.chatId });
expect(doc?.status).toBe(SandboxStatusEnum.running);
expect(doc?.lastActiveAt).toBeDefined();
});
it('should stop sandbox and update status', async () => {
await sandbox.exec('echo test');
await sandbox.stop();
const doc = await MongoSandboxInstance.findOne({ chatId: testParams.chatId });
expect(doc?.status).toBe(SandboxStatusEnum.stoped);
});
it('should update lastActiveAt on each exec', async () => {
await sandbox.exec('echo first');
const firstDoc = await MongoSandboxInstance.findOne({ chatId: testParams.chatId });
const firstTime = firstDoc?.lastActiveAt;
await new Promise((resolve) => setTimeout(resolve, 100));
await sandbox.exec('echo second');
const secondDoc = await MongoSandboxInstance.findOne({ chatId: testParams.chatId });
const secondTime = secondDoc?.lastActiveAt;
expect(secondTime?.getTime()).toBeGreaterThan(firstTime?.getTime() || 0);
});
it('should persist sandbox metadata correctly', async () => {
await sandbox.exec('echo test');
const doc = await MongoSandboxInstance.findOne({ chatId: testParams.chatId });
expect(String(doc?.appId)).toBe(testParams.appId);
expect(doc?.userId).toBe(testParams.userId);
expect(doc?.chatId).toBe(testParams.chatId);
expect(doc?.createdAt).toBeDefined();
});
});
// ===== 批量操作测试 =====
describe('Batch Operations', () => {
it('should delete multiple sandboxes by chatIds', async () => {
const chatId1 = `${testParams.chatId}-1`;
const chatId2 = `${testParams.chatId}-2`;
const sandbox1 = new SandboxClient({ ...testParams, chatId: chatId1 });
const sandbox2 = new SandboxClient({ ...testParams, chatId: chatId2 });
await sandbox1.exec('echo test1');
await sandbox2.exec('echo test2');
await deleteSandboxesByChatIds({
appId: testParams.appId,
chatIds: [chatId1, chatId2]
});
const count = await MongoSandboxInstance.countDocuments({
chatId: { $in: [chatId1, chatId2] }
});
expect(count).toBe(0);
});
it('should delete all sandboxes by appId', async () => {
const chatId1 = `${testParams.chatId}-app-1`;
const chatId2 = `${testParams.chatId}-app-2`;
const sandbox1 = new SandboxClient({ ...testParams, chatId: chatId1 });
const sandbox2 = new SandboxClient({ ...testParams, chatId: chatId2 });
await sandbox1.exec('echo test1');
await sandbox2.exec('echo test2');
await deleteSandboxesByAppId(testParams.appId);
const count = await MongoSandboxInstance.countDocuments({ appId: testParams.appId });
expect(count).toBe(0);
});
it('should handle empty chatIds array gracefully', async () => {
await expect(
deleteSandboxesByChatIds({ appId: testParams.appId, chatIds: [] })
).resolves.not.toThrow();
});
it('should handle non-existent chatIds gracefully', async () => {
await expect(
deleteSandboxesByChatIds({
appId: testParams.appId,
chatIds: ['non-existent-chat-id']
})
).resolves.not.toThrow();
});
});
// ===== 并发和竞态条件 =====
describe('Concurrency', () => {
it('should handle concurrent exec calls on same sandbox', async () => {
// 先确保沙盒已初始化
await sandbox.exec('echo init');
const results = await Promise.all([
sandbox.exec('echo test1'),
sandbox.exec('echo test2'),
sandbox.exec('echo test3')
]);
results.forEach((result) => {
expect(result.exitCode).toBe(0);
});
});
it('should handle concurrent sandbox creation with same chatId', async () => {
const sandbox1 = new SandboxClient(testParams);
const sandbox2 = new SandboxClient(testParams);
const results = await Promise.all([sandbox1.exec('echo test1'), sandbox2.exec('echo test2')]);
results.forEach((result) => {
expect(result.exitCode).toBe(0);
});
const count = await MongoSandboxInstance.countDocuments({ chatId: testParams.chatId });
expect(count).toBe(1); // 应该只有一个文档
});
it('should handle concurrent delete operations', async () => {
await sandbox.exec('echo test');
await Promise.all([
deleteSandboxesByChatIds({ appId: testParams.appId, chatIds: [testParams.chatId] }),
deleteSandboxesByChatIds({ appId: testParams.appId, chatIds: [testParams.chatId] })
]);
const count = await MongoSandboxInstance.countDocuments({ chatId: testParams.chatId });
expect(count).toBe(0);
});
});
// ===== 文件系统持久化测试 =====
describe('Filesystem Persistence', () => {
it('should persist files across multiple exec calls', async () => {
await sandbox.exec(`echo "content" > ${testDir}/test.txt`);
const result1 = await sandbox.exec(`cat ${testDir}/test.txt`);
expect(result1.stdout).toContain('content');
await sandbox.exec(`echo "more" >> ${testDir}/test.txt`);
const result2 = await sandbox.exec(`cat ${testDir}/test.txt`);
expect(result2.stdout).toContain('content');
expect(result2.stdout).toContain('more');
});
it('should handle directory operations', async () => {
await sandbox.exec(`touch ${testDir}/file.txt`);
const result = await sandbox.exec(`ls ${testDir}`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('file.txt');
});
it('should handle file permissions', async () => {
await sandbox.exec(`touch ${testDir}/script.sh`);
await sandbox.exec(`chmod +x ${testDir}/script.sh`);
await sandbox.exec(`echo "#!/bin/bash\necho executed" > ${testDir}/script.sh`);
const result = await sandbox.exec(`${testDir}/script.sh`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('executed');
});
});
// ===== 环境变量和工作目录测试 =====
describe('Environment and Working Directory', () => {
it('should maintain working directory across commands', async () => {
await sandbox.exec(`cd ${testDir} && pwd`);
const result = await sandbox.exec('pwd');
// 注意:每次 exec 可能重置工作目录,这取决于实现
expect(result.exitCode).toBe(0);
});
it('should handle environment variables', async () => {
const result = await sandbox.exec('export TEST_VAR=hello && echo $TEST_VAR');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('hello');
});
});
// ===== 资源限制测试 =====
describe('Resource Limits', () => {
it('should handle large file creation', async () => {
const result = await sandbox.exec(`dd if=/dev/zero of=${testDir}/large.bin bs=1M count=10`);
expect(result.exitCode).toBe(0);
const sizeResult = await sandbox.exec(`ls -lh ${testDir}/large.bin`);
expect(sizeResult.stdout).toContain('10M');
});
it('should handle process spawning', async () => {
// 先确保沙盒已初始化
await sandbox.exec('echo init');
const result = await sandbox.exec('for i in {1..5}; do echo "process $i" & done; wait');
expect(result.exitCode).toBe(0);
});
});
});