Commit 1562b4b7 by DigHuang Committed by GitHub

refactor(sandbox): enforce sandbox validation and sync parallel variables (#7220)

* refactor(sandbox): enforce strict sandbox env validation at startup; fix loop end i18n

* fix(workflow): sync parallel task variables back to parent state on success

* chat schema

* schema

* test

* fix: test

---------

Co-authored-by: archer <545436317@qq.com>
parent be34feb2
......@@ -324,7 +324,7 @@ services:
<<: [*x-no-proxy-config]
PORT: 3000
VM_RUNTIME: docker
VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_VOLUME_MANAGER_TOKEN
VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN
VM_VOLUME_NAME_PREFIX: fastgpt-session # volume 名称前缀
VM_LOG_LEVEL: info
healthcheck:
......
......@@ -324,7 +324,7 @@ services:
<<: [*x-no-proxy-config]
PORT: 3000
VM_RUNTIME: docker
VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_VOLUME_MANAGER_TOKEN
VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN
VM_VOLUME_NAME_PREFIX: fastgpt-session # volume 名称前缀
VM_LOG_LEVEL: info
healthcheck:
......
......@@ -324,7 +324,7 @@ services:
<<: [*x-no-proxy-config]
PORT: 3000
VM_RUNTIME: docker
VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_VOLUME_MANAGER_TOKEN
VM_AUTH_TOKEN: *x-volume-manager-auth-token # 对应 AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN
VM_VOLUME_NAME_PREFIX: fastgpt-session # volume 名称前缀
VM_LOG_LEVEL: info
healthcheck:
......
......@@ -5,30 +5,28 @@ description: 'FastGPT V4.15.0-beta7 Release Notes'
## 📦 Upgrade Guide
This is the final beta release before the 4.15.0 stable release. If you deployed any 4.15.0-beta version, upgrade to this version first, complete all upgrade steps introduced during the beta period, and then update all images to the stable release. See [4.15.0](./41500.mdx) for the stable release images.
### 1. Open-source config.json Configuration Removed
### 1. Remove Open-Source `config.json` Configuration
The `config.json` configuration file has been removed. All settings now use environment variables:
The `config.json` configuration file has been removed. All configuration is now provided through environment variables:
```dotenv
# MCP Server proxy endpoint, used by the MCP usage page to build the SSE URL (do not include a trailing /)
# MCP Server proxy endpoint, used on the MCP usage page to build the SSE URL (do not include a trailing /)
SSE_MCP_SERVER_PROXY_ENDPOINT=http://localhost:3003
# ==================== Enhanced PDF Parsing (Optional) ====================
# Custom PDF parsing service URL
# Custom PDF parsing service endpoint
# CUSTOM_PDF_PARSE_URL=
# Custom PDF parsing service key
# CUSTOM_PDF_PARSE_KEY=
# Doc2x PDF parsing service key
# DOC2X_KEY=
# IntSig TextIn service App ID
# TextIn service App ID
# TEXTIN_APP_ID=
# IntSig TextIn service Secret Code
# TextIn service Secret Code
# TEXTIN_SECRET_CODE=
# Vector search hnsw ef_search parameter. Applies only to PG / OB / OpenGauss
# hnsw ef_search parameter for vector retrieval. Only applies to PG / OB / OpenGauss.
HNSW_EF_SEARCH=100
# Maximum scanned tuple count for vector search. Applies only to PG
# Maximum scanned rows for vector retrieval. Only applies to PG.
HNSW_MAX_SCAN_TUPLES=100000
# ==================== Knowledge Base Processing Concurrency Control ====================
......@@ -42,20 +40,28 @@ QA_MAX_PROCESS=10
VLM_MAX_PROCESS=10
```
### 2. Add the SSE MCP Endpoint for the Commercial Edition
### 2. Commercial Edition: Add the SSE MCP Endpoint
This configuration has been removed from the admin panel. Add the following environment variable to the `fastgpt` service:
This setting has been removed from admin and must now be added as an environment variable to the `fastgpt` service:
```dotenv
SSE_MCP_SERVER_PROXY_ENDPOINT=http://localhost:3003
```
### 3. Update Images
### 3. OpenSandbox Variable Updates
The OpenSandbox Volume Manager configuration is now required, and the environment variables have been renamed to:
- Update the fastgpt-app (FastGPT main service) image tag to v4.15.0-beta7.
- Update the fastgpt-pro (FastGPT Commercial Edition) image tag to v4.15.0-beta7.
```dotenv
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://localhost:3005
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=vmtoken
```
### 4. Run the Workflow V1 to V2 Migration (Optional)
### 4. Update Images
See the [4.15.0 stable image tags](./41500.mdx) and update all images to the stable release.
### 5. Run the Workflow V1 to V2 Migration (Optional)
Only users who have deployed a FastGPT version earlier than `<4.8` need to run this step.
......@@ -96,7 +102,7 @@ Migration behavior:
5. Missing `node.name` falls back to `flowType`, and missing `input.label` falls back to `input.key`.
6. Before writing, the script validates `nodes`, `edges`, and `chatConfig` with `PublishAppBodySchema`. Documents that fail validation are not written and are included in the endpoint response.
### 5. Run the Workflow V2 Enum and Structure Cleanup
### 6. Run the Workflow V2 Enum and Structure Cleanup
Some historical workflow nodes may have stored TypeScript enum expression strings directly in MongoDB, for example:
......@@ -154,13 +160,47 @@ Cleanup behavior:
The response includes separate statistics for `apps`, `appVersions`, and `total`, including scanned documents, fixable documents, Zod error count, successful writes, failed writes, enum expression statistics, change samples, and error samples.
## ⚙️ Optimizations
### 7. Clean Up Duplicate Chat Headers
Some historical data may contain duplicate `chats` records with the same `appId + chatId`, which can prevent the new unique index from being created. After upgrading, run the duplicate chat header cleanup script to keep the record with the latest `updateTime`. If multiple records have the same `updateTime`, the record with the largest `_id` is kept.
Migration script path: `projects/app/src/pages/api/admin/dataClean/cleanupDuplicateChats.ts`. This endpoint is only for this upgrade migration and is not a public OpenAPI endpoint.
The endpoint uses dry-run mode by default. It scans duplicate groups and returns samples without deleting data:
```bash
curl -X POST 'https://your-domain/api/admin/dataClean/cleanupDuplicateChats' \
-H 'Content-Type: application/json' \
-H 'rootkey: YOUR_ROOT_KEY' \
-d '{"dryRun":true,"sampleLimit":20}'
```
After confirming the returned statistics, set `dryRun` to `false` to delete duplicates:
```bash
curl -X POST 'https://your-domain/api/admin/dataClean/cleanupDuplicateChats' \
-H 'Content-Type: application/json' \
-H 'rootkey: YOUR_ROOT_KEY' \
-d '{"dryRun":false,"sampleLimit":20}'
```
Request parameters:
| Parameter | Type | Default | Description |
| ------------- | ------- | ------- | ------------------------------------------------------------ |
| `dryRun` | boolean | `true` | Whether to scan and report statistics without deleting. |
| `sampleLimit` | number | `20` | Number of duplicate group samples to return. Range: `0~100`. |
Cleanup behavior:
1. Virtual machine file URLs now use the new API.
1. Scans duplicate chat headers in the `chats` collection by `appId + chatId`.
2. Keeps the record with the latest `updateTime`; if timestamps are equal, `_id` descending order is used as a stable fallback.
3. In non-dry-run mode, deletes only duplicate `chats` headers. Messages in `chatitems` and `chat_item_responses` are not deleted.
4. The response includes duplicate group count, estimated delete count, actual delete count, and duplicate group samples.
## 🐛 Fixes
1. Fixed historical V1 workflow data that could fail validation under the new save payload structure.
2. Fixed dirty `FlowNodeInputTypeEnum.*`, `FlowNodeOutputTypeEnum.*`, and `WorkflowIOValueTypeEnum.*` expression strings in workflow node configuration that could break input rendering and IO type checks.
3. Fixed AgentV2 MCP not being able to retrieve schemas.
4. Fixed variable updates not being written back at the end of batch execution nodes.
4. Fixed workflow text boxes where pressing Ctrl+C while selecting text could be intercepted by node copy handling, preventing text from being copied.
......@@ -50,12 +50,21 @@ VLM_MAX_PROCESS=10
SSE_MCP_SERVER_PROXY_ENDPOINT=http://localhost:3003
```
### 3. 更新镜像
### 3. OpenSandbox 变量更新
OpenSandbox Volume Manager 配置变为必填,并且环境变量改名为:
```dotenv
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://localhost:3005
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=vmtoken
```
### 4. 更新镜像
- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.15.0-beta7
- 更新 fastgpt-pro(fastgpt 商业版) 镜像 tag: v4.15.0-beta7
### 4. 执行工作流 V1 升级 V2 迁移(可选)
### 5. 执行工作流 V1 升级 V2 迁移(可选)
该步骤仅需部署过 `<4.8` 版本 FastGPT 的用户执行。
......@@ -96,7 +105,7 @@ curl -X POST 'https://你的域名/api/admin/dataClean/v1WorkflowToV2' \
5. 缺失 `node.name` 时用 `flowType` 兜底,缺失 `input.label` 时用 `input.key` 兜底。
6. 写库前使用 `PublishAppBodySchema` 校验 `nodes`、`edges`、`chatConfig`,校验失败的文档不会写入,并会记录到接口返回结果。
### 5. 执行工作流 V2 枚举与结构脏数据清洗
### 6. 执行工作流 V2 枚举与结构脏数据清洗
部分历史工作流节点可能把 TypeScript 枚举表达式字符串直接写入 MongoDB,例如:
......@@ -154,6 +163,44 @@ curl -X POST 'https://你的域名/api/admin/dataClean/initWorkflowData' \
返回结果会分别展示 `apps`、`appVersions` 和 `total` 的统计,包括扫描文档数、可修复文档数、Zod 错误数量、写入成功数量、写入失败数量、枚举表达式统计、变更样本和错误样本。
### 7. 清理重复 Chat 会话头
部分历史数据可能存在相同 `appId + chatId` 的重复 `chats` 会话头,导致新版本创建唯一索引失败。升级后可执行重复会话头清理脚本,保留 `updateTime` 最新的一条记录;如果 `updateTime` 相同,则保留 `_id` 最大的一条。
迁移脚本位置:`projects/app/src/pages/api/admin/dataClean/cleanupDuplicateChats.ts`。该接口仅用于本次升级迁移,不作为 OpenAPI 对外接口。
接口默认 dry-run,只扫描重复组并返回样本,不删除数据:
```bash
curl -X POST 'https://你的域名/api/admin/dataClean/cleanupDuplicateChats' \
-H 'Content-Type: application/json' \
-H 'rootkey: 你的ROOT_KEY' \
-d '{"dryRun":true,"sampleLimit":20}'
```
确认返回统计无误后,将 `dryRun` 改为 `false` 执行删除:
```bash
curl -X POST 'https://你的域名/api/admin/dataClean/cleanupDuplicateChats' \
-H 'Content-Type: application/json' \
-H 'rootkey: 你的ROOT_KEY' \
-d '{"dryRun":false,"sampleLimit":20}'
```
接口参数:
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ------- | ------ | ---------------------------------------- |
| `dryRun` | boolean | `true` | 是否只扫描统计不删除。 |
| `sampleLimit` | number | `20` | 返回重复组样本数量,取值范围为 `0~100`。 |
清理逻辑:
1. 按 `appId + chatId` 扫描 `chats` 集合中的重复会话头。
2. 每组保留 `updateTime` 最新的一条;若时间相同,用 `_id` 倒序作为稳定兜底。
3. 非 dry-run 时只删除重复的 `chats` 会话头,不删除 `chatitems` 和 `chat_item_responses` 中的消息内容。
4. 返回结果包含重复组数量、预计删除数量、实际删除数量和重复组样本。
## ⚙️ 优化
1. 虚拟机文件地址使用新 API。
......@@ -164,3 +211,4 @@ curl -X POST 'https://你的域名/api/admin/dataClean/initWorkflowData' \
2. 修复工作流节点配置中 `FlowNodeInputTypeEnum.*`、`FlowNodeOutputTypeEnum.*` 和 `WorkflowIOValueTypeEnum.*` 枚举表达式字符串脏数据导致输入渲染和 IO 类型判断异常的问题。
3. AgentV2 mcp 拿不到 schema。
4. 批量执行节点最后未回写变量更新。
5. 工作流文本框,ctrl+c 复制文本内容时,会被节点复制抢占,导致无法复制文本。
......@@ -167,8 +167,8 @@
"content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/system-tool-development.en.mdx": "2026-06-27T22:05:51+08:00",
"content/plugin/system-tool-development.mdx": "2026-06-27T22:05:51+08:00",
"content/self-host/config/env.en.mdx": "2026-06-30T12:13:17+08:00",
"content/self-host/config/env.mdx": "2026-06-30T12:13:17+08:00",
"content/self-host/config/env.en.mdx": "2026-06-30T16:19:46+08:00",
"content/self-host/config/env.mdx": "2026-06-30T16:19:46+08:00",
"content/self-host/config/json.en.mdx": "2026-06-22T11:01:59+08:00",
"content/self-host/config/json.mdx": "2026-06-22T11:01:59+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
......@@ -181,10 +181,10 @@
"content/self-host/config/object-storage.mdx": "2026-05-21T11:24:48+08:00",
"content/self-host/config/remote-debug-suite.en.mdx": "2026-06-27T22:05:51+08:00",
"content/self-host/config/remote-debug-suite.mdx": "2026-06-27T22:05:51+08:00",
"content/self-host/config/sandbox/common.en.mdx": "2026-06-30T13:59:36+08:00",
"content/self-host/config/sandbox/common.mdx": "2026-06-30T13:59:36+08:00",
"content/self-host/config/sandbox/sealosdevbox.en.mdx": "2026-06-30T13:59:36+08:00",
"content/self-host/config/sandbox/sealosdevbox.mdx": "2026-06-30T13:59:36+08:00",
"content/self-host/config/sandbox/common.en.mdx": "2026-06-30T14:56:33+08:00",
"content/self-host/config/sandbox/common.mdx": "2026-06-30T14:56:33+08:00",
"content/self-host/config/sandbox/sealosdevbox.en.mdx": "2026-06-30T14:56:33+08:00",
"content/self-host/config/sandbox/sealosdevbox.mdx": "2026-06-30T14:56:33+08:00",
"content/self-host/config/signoz.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/config/signoz.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/custom-models/bge-rerank.en.mdx": "2026-04-26T21:08:47+08:00",
......@@ -310,8 +310,8 @@
"content/self-host/upgrading/4-15/41505.mdx": "2026-06-29T10:45:58+08:00",
"content/self-host/upgrading/4-15/41506.en.mdx": "2026-06-29T00:47:43+08:00",
"content/self-host/upgrading/4-15/41506.mdx": "2026-06-29T00:47:43+08:00",
"content/self-host/upgrading/4-15/41507.en.mdx": "2026-06-30T14:32:39+08:00",
"content/self-host/upgrading/4-15/41507.mdx": "2026-06-30T14:32:39+08:00",
"content/self-host/upgrading/4-15/41507.en.mdx": "2026-06-30T16:19:46+08:00",
"content/self-host/upgrading/4-15/41507.mdx": "2026-06-30T16:19:46+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......@@ -452,6 +452,6 @@
"content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00",
"content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00",
"content/toc.en.mdx": "2026-06-30T13:59:36+08:00",
"content/toc.mdx": "2026-06-30T13:59:36+08:00"
"content/toc.en.mdx": "2026-06-30T14:56:33+08:00",
"content/toc.mdx": "2026-06-30T14:56:33+08:00"
}
\ No newline at end of file
import { agentSandboxProviderList } from './constants';
import type { SandboxProviderType } from '@fastgpt-sdk/sandbox-adapter';
export const agentSandboxProviderRequiredEnvKeys = {
sealosdevbox: [
'AGENT_SANDBOX_SEALOS_BASEURL',
'AGENT_SANDBOX_SEALOS_TOKEN',
'AGENT_SANDBOX_SEALOS_IMAGE'
],
opensandbox: ['AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'AGENT_SANDBOX_OPENSANDBOX_API_KEY'],
e2b: ['AGENT_SANDBOX_E2B_API_KEY']
} satisfies Record<SandboxProviderType, readonly string[]>;
export type AgentSandboxEnvSource = Record<string, string | undefined>;
const isAgentSandboxProvider = (provider: string | undefined): provider is SandboxProviderType =>
agentSandboxProviderList.includes(provider as SandboxProviderType);
/**
* 判断系统是否显式配置了 Agent 虚拟机能力。
* 必须基于原始 env 判断,避免被服务端 env schema 的默认 provider 误判为已启用。
*/
export const hasAgentSandboxConfig = (env: AgentSandboxEnvSource): boolean => {
const provider = env.AGENT_SANDBOX_PROVIDER;
if (!isAgentSandboxProvider(provider)) {
return false;
}
return agentSandboxProviderRequiredEnvKeys[provider].every((key) => !!env[key]);
};
import { describe, expect, it } from 'vitest';
import { agentSandboxProviderList } from '@fastgpt/global/core/ai/sandbox/constants';
import {
agentSandboxProviderRequiredEnvKeys,
hasAgentSandboxConfig
} from '@fastgpt/global/core/ai/sandbox/env';
describe('agent sandbox env config', () => {
it('keeps provider list aligned with required env keys', () => {
expect(Object.keys(agentSandboxProviderRequiredEnvKeys).sort()).toEqual(
[...agentSandboxProviderList].sort()
);
});
it('requires all provider env keys before enabling agent sandbox', () => {
expect(
hasAgentSandboxConfig({
AGENT_SANDBOX_PROVIDER: 'sealosdevbox',
AGENT_SANDBOX_SEALOS_BASEURL: 'https://devbox.example.com',
AGENT_SANDBOX_SEALOS_TOKEN: 'token'
})
).toBe(false);
expect(
hasAgentSandboxConfig({
AGENT_SANDBOX_PROVIDER: 'sealosdevbox',
AGENT_SANDBOX_SEALOS_BASEURL: 'https://devbox.example.com',
AGENT_SANDBOX_SEALOS_TOKEN: 'token',
AGENT_SANDBOX_SEALOS_IMAGE: 'runtime/fastgpt:stable'
})
).toBe(true);
});
it('ignores missing or unsupported providers', () => {
expect(hasAgentSandboxConfig({})).toBe(false);
expect(hasAgentSandboxConfig({ AGENT_SANDBOX_PROVIDER: 'unknown' })).toBe(false);
});
});
......@@ -18,8 +18,8 @@ export type VolumeManagerConfig = {
*/
export function getVolumeManagerEnvConfig(): VolumeManagerConfig {
return {
enable: serviceEnv.AGENT_SANDBOX_ENABLE_VOLUME,
url: serviceEnv.AGENT_SANDBOX_VOLUME_MANAGER_URL!,
token: serviceEnv.AGENT_SANDBOX_VOLUME_MANAGER_TOKEN
enable: true,
url: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL!,
token: serviceEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN
};
}
......@@ -91,9 +91,7 @@ export const getSessionVolumeConfig = async (
const vmConfig = getVolumeManagerEnvConfig();
if (!vmConfig.enable) return undefined;
if (!vmConfig.url) {
throw new Error(
'AGENT_SANDBOX_VOLUME_MANAGER_URL is required when AGENT_SANDBOX_ENABLE_VOLUME=true'
);
throw new Error('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL is required');
}
const claimName = await ensureSessionVolume(sandboxId);
const volumeResult = buildVolumeConfig(claimName);
......
......@@ -13,7 +13,8 @@ const ChatItemResponseSchema = new Schema({
},
sourceType: {
type: String,
enum: Object.values(ChatSourceTypeEnum)
enum: Object.values(ChatSourceTypeEnum),
required: true
},
// 历史物理字段名,业务语义为 sourceId;App 场景才是真实 appId。
appId: {
......@@ -39,6 +40,7 @@ const ChatItemResponseSchema = new Schema({
}
});
/* TODO: 未全面检查操作,所以这里暂时不加 sourceType 的索引。 */
// 按 chat item 拉取完整 nodeResponse rows;复合索引包含 _id,避免详情读取时额外排序。
ChatItemResponseSchema.index({ appId: 1, chatId: 1, chatItemDataId: 1, _id: 1 });
ChatItemResponseSchema.index({
......@@ -48,7 +50,6 @@ ChatItemResponseSchema.index({
chatItemDataId: 1,
_id: 1
});
// Clear expired response
ChatItemResponseSchema.index({ teamId: 1, time: -1 });
......
......@@ -32,7 +32,8 @@ const ChatItemSchema = new Schema({
},
sourceType: {
type: String,
enum: Object.values(ChatSourceTypeEnum)
enum: Object.values(ChatSourceTypeEnum),
required: true
},
dataId: {
type: String,
......@@ -96,6 +97,7 @@ const ChatItemSchema = new Schema({
}
});
/* TODO: 未全面检查操作,所以这里暂时不加 sourceType 的索引。 */
/*
delete by app;
delete by chat id;
......
import { connectionMongo, getMongoModel } from '../../common/mongo';
import { getLogger, LogCategories } from '../../common/logger';
const { Schema } = connectionMongo;
import { type ChatSchemaType } from '@fastgpt/global/core/chat/type';
import {
......@@ -31,7 +30,8 @@ const ChatSchema = new Schema({
},
sourceType: {
type: String,
enum: Object.values(ChatSourceTypeEnum)
enum: Object.values(ChatSourceTypeEnum),
required: true
},
// 历史物理字段名,业务语义为 sourceId;App 场景才是真实 appId。
appId: {
......@@ -133,135 +133,120 @@ const ChatSchema = new Schema({
userId: Schema.Types.ObjectId
});
try {
ChatSchema.index({ chatId: 1 });
// Delete by appid; init chat; update chat; auth chat;
ChatSchema.index({ appId: 1, chatId: 1 }, { unique: true });
ChatSchema.index(
{ sourceType: 1, appId: 1, chatId: 1 },
{ unique: true, name: 'sourceType_1_appId_1_chatId_1' }
);
ChatSchema.index({ chatId: 1 });
// Delete by appid; init chat; update chat; auth chat;
ChatSchema.index({ sourceType: 1, appId: 1, chatId: 1 }, { unique: true });
// timer, clear history
ChatSchema.index({ updateTime: -1, teamId: 1 });
ChatSchema.index({ teamId: 1, updateTime: -1 });
// get user history(Cookie)
ChatSchema.index({ tmbId: 1, appId: 1, deleteTime: 1, top: -1, updateTime: -1 });
// Clear history(share),Init 4121
ChatSchema.index(
{ appId: 1, outLinkUid: 1, tmbId: 1 },
{
partialFilterExpression: {
outLinkUid: { $exists: true }
}
/* ===== 条件索引 ===== */
// Clear history(share),Init 4121
ChatSchema.index(
{ appId: 1, outLinkUid: 1, tmbId: 1 },
{
partialFilterExpression: {
outLinkUid: { $exists: true }
}
);
}
);
// get user history
ChatSchema.index({ tmbId: 1, appId: 1, deleteTime: 1, top: -1, updateTime: -1 });
ChatSchema.index({
sourceType: 1,
tmbId: 1,
appId: 1,
deleteTime: 1,
top: -1,
updateTime: -1
});
// get share chat history
ChatSchema.index(
{ shareId: 1, outLinkUid: 1, updateTime: -1 },
{
partialFilterExpression: {
shareId: { $exists: true }
}
// get share chat history
ChatSchema.index(
{ shareId: 1, outLinkUid: 1, updateTime: -1 },
{
partialFilterExpression: {
shareId: { $exists: true }
}
);
}
);
/* get chat logs */
// 1. Common get
ChatSchema.index({ appId: 1, updateTime: -1 });
// Get history(tmbId)
ChatSchema.index({ appId: 1, tmbId: 1, updateTime: -1 });
// clearHistory(API)
ChatSchema.index({ appId: 1, source: 1, tmbId: 1, updateTime: -1 });
// Periodic cleanup for chats stuck in generating state.
ChatSchema.index(
{ chatGenerateStatus: 1, updateTime: 1 },
{
partialFilterExpression: {
chatGenerateStatus: ChatGenerateStatusEnum.generating
}
/* get chat logs */
// 1. Common get
ChatSchema.index({ appId: 1, updateTime: -1 });
// Get history(tmbId)
ChatSchema.index({ appId: 1, tmbId: 1, updateTime: -1 });
// clearHistory(API)
ChatSchema.index({ appId: 1, source: 1, tmbId: 1, updateTime: -1 });
// Periodic cleanup for chats stuck in generating state.
ChatSchema.index(
{ chatGenerateStatus: 1, updateTime: 1 },
{
partialFilterExpression: {
chatGenerateStatus: ChatGenerateStatusEnum.generating
}
);
}
);
/* 反馈过滤的索引 */
// 2. Has good feedback filter
ChatSchema.index(
{
appId: 1,
hasGoodFeedback: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasGoodFeedback: true
}
/* 反馈过滤的索引 */
// 2. Has good feedback filter
ChatSchema.index(
{
appId: 1,
hasGoodFeedback: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasGoodFeedback: true
}
);
// Has bad feedback filter
ChatSchema.index(
{
appId: 1,
hasBadFeedback: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasBadFeedback: true
}
}
);
// Has bad feedback filter
ChatSchema.index(
{
appId: 1,
hasBadFeedback: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasBadFeedback: true
}
);
// 3. Has unread good feedback filter
ChatSchema.index(
{
appId: 1,
hasUnreadGoodFeedback: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasUnreadGoodFeedback: true
}
}
);
// 3. Has unread good feedback filter
ChatSchema.index(
{
appId: 1,
hasUnreadGoodFeedback: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasUnreadGoodFeedback: true
}
);
// Has unread bad feedback filter
ChatSchema.index(
{
appId: 1,
hasUnreadBadFeedback: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasUnreadBadFeedback: true
}
}
);
// Has unread bad feedback filter
ChatSchema.index(
{
appId: 1,
hasUnreadBadFeedback: 1,
updateTime: -1
},
{
partialFilterExpression: {
hasUnreadBadFeedback: true
}
);
// Has error filter
ChatSchema.index(
{
appId: 1,
errorCount: 1,
updateTime: -1
},
{
partialFilterExpression: {
errorCount: { $gt: 0 }
}
}
);
// Has error filter
ChatSchema.index(
{
appId: 1,
errorCount: 1,
updateTime: -1
},
{
partialFilterExpression: {
errorCount: { $gt: 0 }
}
);
// timer, clear history
ChatSchema.index({ updateTime: -1, teamId: 1 });
ChatSchema.index({ teamId: 1, updateTime: -1 });
} catch (error) {
const logger = getLogger(LogCategories.INFRA.MONGO);
logger.error('Failed to build chat indexes', { error });
}
}
);
export const MongoChat = getMongoModel<ChatSchemaType>(chatCollectionName, ChatSchema);
......@@ -93,9 +93,10 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
: `${taskResponseIdPrefix}_task_${index}`;
try {
const taskVariableState = props.variableState.clone();
const response = await runWorkflow({
...props,
variableState: props.variableState.clone(),
variableState: taskVariableState,
nodeResponseParentId: taskResponseId,
runtimeNodes: taskRuntimeNodes,
runtimeEdges: taskRuntimeEdges
......@@ -111,6 +112,12 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
accumulatedPoints += attemptPoints;
const result = parseTaskResponse({ index, response });
if (result.success) {
const taskVariables = taskVariableState.toRuntimeRecord();
for (const [key, value] of Object.entries(taskVariables)) {
await props.variableState.set(key, value);
}
}
const attemptResult = {
...result,
taskResponseId,
......
import z from 'zod';
// 系统最大字符串处理长度
export const SYSTEM_STRING_LENGTH_UNIT = 1_000_000;
// Log 枚举
export const LogLevelSchema = z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']);
// S3
export const StorageVendorSchema = z.enum(['minio', 'aws-s3', 'cos', 'oss']);
export const StorageCosProtocolSchema = z.enum(['https:', 'http:']);
......@@ -4,38 +4,24 @@ import { isPhaseProductionBuild } from '@fastgpt/global/common/system/constants'
import { DEFAULT_MAX_FOLDER_DEPTH } from '@fastgpt/global/common/parentFolder/depth';
import { BoolSchema, IntSchema, NumSchema, UrlSchema } from '@fastgpt/global/common/zod';
import { agentSandboxProviderList } from '@fastgpt/global/core/ai/sandbox/constants';
import { hasAgentSandboxConfig as hasAgentSandboxConfigFromEnv } from '@fastgpt/global/core/ai/sandbox/env';
import {
AgentSandboxProxyUrlSchema,
getAgentSandboxMissingRequiredEnvKeys,
getRuntimeEnv,
isAgentSandboxProvider
} from './env.util';
import {
LogLevelSchema,
StorageVendorSchema,
StorageCosProtocolSchema,
SYSTEM_STRING_LENGTH_UNIT
} from './env.const';
const defaultableIntSchema = (defaultValue: number) =>
z.preprocess(
(value) => (value === '' || value === undefined ? defaultValue : value),
z.coerce.number<number>().int().nonnegative()
);
// 系统最大字符串处理长度
const SYSTEM_STRING_LENGTH_UNIT = 1_000_000;
// 枚举
const LogLevelSchema = z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']);
const StorageVendorSchema = z.enum(['minio', 'aws-s3', 'cos', 'oss']);
const StorageCosProtocolSchema = z.enum(['https:', 'http:']);
const AgentSandboxProxyUrlSchema = z.string().refine((url) => /^wss?:\/\//.test(url), {
message: 'AGENT_SANDBOX_PROXY_URL must start with ws:// or wss://'
});
const TEST_INVOKE_TOKEN_SECRET = 'fastgpt_test_invoke_token_secret_32';
/**
* 测试套件会在多个 workspace(包含 pro/admin 子模块)里直接导入 serviceEnv。
* 生产启动仍要求显式配置 INVOKE_TOKEN_SECRET;仅 Vitest/测试环境允许注入稳定测试密钥,
* 避免每个测试项目都重复维护同一个必填运行时密钥。
*/
const getRuntimeEnv = (): NodeJS.ProcessEnv => ({
...process.env,
INVOKE_TOKEN_SECRET:
process.env.INVOKE_TOKEN_SECRET ??
(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'
? TEST_INVOKE_TOKEN_SECRET
: undefined)
});
export const serviceEnv = createEnv({
skipValidation: isPhaseProductionBuild,
......@@ -45,16 +31,19 @@ export const serviceEnv = createEnv({
SYNC_INDEX: BoolSchema.default(true),
// ==================== 密钥 ====================
ROOT_KEY: z
.string()
.min(6, 'ROOT_KEY must be at least 6 characters')
.default('fastgpt_root_key'),
TOKEN_KEY: z
.string()
.min(6, 'TOKEN_KEY must be at least 6 characters')
.default('fastgpt_token_key'),
FILE_TOKEN_KEY: z.string().min(6, 'FILE_TOKEN_KEY must be at least 6 characters'),
AES256_SECRET_KEY: z.string().min(6, 'AES256_SECRET_KEY must be at least 6 characters'),
ROOT_KEY: z
.string()
.min(6, 'ROOT_KEY must be at least 6 characters')
.default('fastgpt_root_key'),
// Invoke 反向调用相关。该密钥用于签发/校验插件反向调用 JWT,必须显式配置,避免未配置时落到公开默认值。
INVOKE_TOKEN_SECRET: z.string().min(32, 'INVOKE_TOKEN_SECRET must be at least 32 characters'),
// ==================== 服务地址与集成 ====================
// 插件
......@@ -96,9 +85,8 @@ export const serviceEnv = createEnv({
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: z.string().default('fastgpt-agent-sandbox'),
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: z.string().default('latest'),
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: BoolSchema.default(true),
AGENT_SANDBOX_ENABLE_VOLUME: BoolSchema.default(false),
AGENT_SANDBOX_VOLUME_MANAGER_URL: UrlSchema.default('http://localhost:3005'),
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: z.string().optional(),
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL: UrlSchema.optional(),
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN: z.string().optional(),
AGENT_SANDBOX_DISK_MB: NumSchema.min(1).default(1024).meta({
description:
'Agent sandbox 磁盘大小基准(MB)。冷归档包上限等于该值,Skill 包和 IDE 单文件上限按该值的一半四舍五入计算。'
......@@ -357,10 +345,7 @@ export const serviceEnv = createEnv({
FEISHU_BASE_URL: UrlSchema.default('https://open.feishu.cn'),
DINGTALK_BASE_URL: UrlSchema.default('https://api.dingtalk.com'),
DINGTALK_OAPI_BASE_URL: UrlSchema.default('https://oapi.dingtalk.com'),
YUQUE_DATASET_BASE_URL: UrlSchema.default('https://www.yuque.com'),
// Invoke 反向调用相关。该密钥用于签发/校验插件反向调用 JWT,必须显式配置,避免未配置时落到公开默认值。
INVOKE_TOKEN_SECRET: z.string().min(32, 'INVOKE_TOKEN_SECRET must be at least 32 characters')
YUQUE_DATASET_BASE_URL: UrlSchema.default('https://www.yuque.com')
},
emptyStringAsUndefined: true,
runtimeEnv: getRuntimeEnv(),
......@@ -370,16 +355,22 @@ export const serviceEnv = createEnv({
}
});
/* ===== Check ===== */
if (serviceEnv.WORKFLOW_PARALLEL_MAX_CONCURRENCY > serviceEnv.WORKFLOW_MAX_LOOP_TIMES) {
throw new Error(
`Invalid environment configuration: WORKFLOW_PARALLEL_MAX_CONCURRENCY (${serviceEnv.WORKFLOW_PARALLEL_MAX_CONCURRENCY}) must not exceed WORKFLOW_MAX_LOOP_TIMES (${serviceEnv.WORKFLOW_MAX_LOOP_TIMES})`
);
}
if (!isPhaseProductionBuild && hasAgentSandboxConfigFromEnv(process.env)) {
if (!serviceEnv.AGENT_SANDBOX_PROXY_URL) {
if (!isPhaseProductionBuild) {
// 共享 serviceEnv 会被 pro/admin 等项目导入,这里只校验 provider 运行态必填环境变量。
// 主站浏览器直连 agent-sandbox-proxy 的配置由 projects/app 启动流程单独校验。
const missingAgentSandboxEnvKeys = getAgentSandboxMissingRequiredEnvKeys(process.env);
if (missingAgentSandboxEnvKeys.length > 0) {
throw new Error(
'AGENT_SANDBOX_PROXY_URL is required when Agent Sandbox is enabled. Please configure a browser-accessible ws:// or wss:// agent-sandbox-proxy URL.'
`Invalid Agent Sandbox environment variables: ${missingAgentSandboxEnvKeys.join(
', '
)} are required when AGENT_SANDBOX_PROVIDER is ${serviceEnv.AGENT_SANDBOX_PROVIDER}.`
);
}
}
......@@ -388,7 +379,8 @@ export const SYSTEM_MAX_STRING_LENGTH =
serviceEnv.SYSTEM_MAX_STRING_LENGTH_M * SYSTEM_STRING_LENGTH_UNIT;
/**
* 判断系统是否显式配置了 Agent 虚拟机能力
* 必须直读 process.env,避免空环境被 schema 默认值误判为已启用
* 判断系统是否显式配置了 Agent 虚拟机 provider
* 启动阶段会先校验 provider 配套 env,避免前端拿到半配置的沙盒能力
*/
export const hasAgentSandboxConfig = (): boolean => hasAgentSandboxConfigFromEnv(process.env);
export const hasAgentSandboxConfig = (): boolean =>
isAgentSandboxProvider(process.env.AGENT_SANDBOX_PROVIDER);
import type { SandboxProviderType } from '@fastgpt-sdk/sandbox-adapter';
import { agentSandboxProviderList } from '@fastgpt/global/core/ai/sandbox/constants';
import z from 'zod';
const TEST_INVOKE_TOKEN_SECRET = 'fastgpt_test_invoke_token_secret_32';
/**
* 测试套件会在多个 workspace(包含 pro/admin 子模块)里直接导入 serviceEnv。
* 生产启动仍要求显式配置 INVOKE_TOKEN_SECRET;仅 Vitest/测试环境允许注入稳定测试密钥,
* 避免每个测试项目都重复维护同一个必填运行时密钥。
*/
export const getRuntimeEnv = (): NodeJS.ProcessEnv => ({
...process.env,
INVOKE_TOKEN_SECRET:
process.env.INVOKE_TOKEN_SECRET ??
(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'
? TEST_INVOKE_TOKEN_SECRET
: undefined)
});
/* ===== sandbox ===== */
export const AgentSandboxProxyUrlSchema = z.string().refine((url) => /^wss?:\/\//.test(url), {
message: 'AGENT_SANDBOX_PROXY_URL must start with ws:// or wss://'
});
const agentSandboxProviderRequiredEnvKeys = {
sealosdevbox: [
'AGENT_SANDBOX_SEALOS_BASEURL',
'AGENT_SANDBOX_SEALOS_TOKEN',
'AGENT_SANDBOX_SEALOS_IMAGE'
],
opensandbox: [
'AGENT_SANDBOX_OPENSANDBOX_BASEURL',
'AGENT_SANDBOX_OPENSANDBOX_API_KEY',
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL',
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN'
],
e2b: ['AGENT_SANDBOX_E2B_API_KEY']
} satisfies Record<SandboxProviderType, readonly string[]>;
export const isAgentSandboxProvider = (
provider: string | undefined
): provider is SandboxProviderType =>
agentSandboxProviderList.includes(provider as SandboxProviderType);
/**
* 获取已配置 provider 缺失的运行态必填环境变量。
* 未配置合法 provider 时不启用 Agent Sandbox,因此不要求任何 provider 配套变量。
*/
export const getAgentSandboxMissingRequiredEnvKeys = (env: NodeJS.ProcessEnv): string[] => {
const provider = env.AGENT_SANDBOX_PROVIDER;
if (!isAgentSandboxProvider(provider)) {
return [];
}
return agentSandboxProviderRequiredEnvKeys[provider].filter((key) => !env[key]);
};
/* ===== Sandbox proxy ===== */
const agentSandboxProxyRequiredEnvKeys = [
'AGENT_SANDBOX_PROXY_SECRET',
'AGENT_SANDBOX_PROXY_URL'
] as const;
/**
* 校验 FastGPT app 浏览器直连 agent-sandbox-proxy 所需环境变量。
* 该能力只属于主站 app 的 sandbox editor/proxy 链路,不能放在共享 serviceEnv 中校验,
* 否则 pro/admin 等只复用服务端能力的项目会被不必要的 proxy 配置阻塞。
*/
export const validateAgentSandboxProxyEnv = (): void => {
const provider = process.env.AGENT_SANDBOX_PROVIDER;
if (!agentSandboxProviderList.includes(provider as (typeof agentSandboxProviderList)[number])) {
return;
}
const missingAgentSandboxProxyEnvKeys = agentSandboxProxyRequiredEnvKeys.filter(
(key) => !process.env[key]
);
if (missingAgentSandboxProxyEnvKeys.length === 0) {
return;
}
throw new Error(
`Invalid Agent Sandbox proxy environment variables: ${missingAgentSandboxProxyEnvKeys.join(
', '
)} are required when AGENT_SANDBOX_PROVIDER is ${provider}.`
);
};
......@@ -18,16 +18,41 @@ import {
upsertRunningSandboxInstance
} from '@fastgpt/service/core/ai/sandbox/infrastructure/instance/repository';
import { connectionMongo } from '@fastgpt/service/common/mongo';
import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { hasAgentSandboxConfig } from '@fastgpt/global/core/ai/sandbox/env';
import {
agentSandboxProviderList,
SandboxStatusEnum,
SandboxTypeEnum
} from '@fastgpt/global/core/ai/sandbox/constants';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { delay } from '@fastgpt/global/common/system/utils';
import { getRunningSandboxId } from '@fastgpt/service/core/ai/sandbox/utils/id';
import type { SandboxProviderType } from '@fastgpt-sdk/sandbox-adapter';
const { Types } = connectionMongo;
const hasSandboxEnv =
process.env.SANDBOX_INTEGRATION === 'true' && hasAgentSandboxConfig(process.env);
const agentSandboxProviderRequiredEnvKeys = {
sealosdevbox: [
'AGENT_SANDBOX_SEALOS_BASEURL',
'AGENT_SANDBOX_SEALOS_TOKEN',
'AGENT_SANDBOX_SEALOS_IMAGE'
],
opensandbox: ['AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'AGENT_SANDBOX_OPENSANDBOX_API_KEY'],
e2b: ['AGENT_SANDBOX_E2B_API_KEY']
} satisfies Record<SandboxProviderType, readonly string[]>;
const isAgentSandboxProvider = (provider: string | undefined): provider is SandboxProviderType =>
agentSandboxProviderList.includes(provider as SandboxProviderType);
const hasFullAgentSandboxEnv = (): boolean => {
const provider = process.env.AGENT_SANDBOX_PROVIDER;
if (!isAgentSandboxProvider(provider)) {
return false;
}
return agentSandboxProviderRequiredEnvKeys[provider].every((key) => !!process.env[key]);
};
const hasSandboxEnv = process.env.SANDBOX_INTEGRATION === 'true' && hasFullAgentSandboxEnv();
const runFullIntegration = process.env.SANDBOX_INTEGRATION_FULL === 'true';
vi.mock('@fastgpt/service/env', () => ({
......@@ -51,9 +76,10 @@ vi.mock('@fastgpt/service/env', () => ({
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY: envBool(
process.env.AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY
),
AGENT_SANDBOX_ENABLE_VOLUME: envBool(process.env.AGENT_SANDBOX_ENABLE_VOLUME),
AGENT_SANDBOX_VOLUME_MANAGER_URL: process.env.AGENT_SANDBOX_VOLUME_MANAGER_URL,
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: process.env.AGENT_SANDBOX_VOLUME_MANAGER_TOKEN,
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL:
process.env.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL,
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN:
process.env.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN,
AGENT_SANDBOX_DISK_MB: agentSandboxDiskMB,
AGENT_SANDBOX_E2B_API_KEY: process.env.AGENT_SANDBOX_E2B_API_KEY
......
......@@ -36,6 +36,10 @@ vi.mock('@fastgpt/service/core/ai/sandbox/application/runtime/mirrors', () => ({
prepareSandboxRuntimeMirrors: mirrorMock.prepareSandboxRuntimeMirrors
}));
vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/provider/runtimeProfile', () => ({
getSandboxRuntimeProfile: () => ({ workDirectory: '/workspace' })
}));
vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({
getS3ChatSource: () => ({
uploadChatFile: s3Mock.uploadChatFile,
......
......@@ -12,6 +12,10 @@ const originalEnv = {
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: process.env.AGENT_SANDBOX_OPENSANDBOX_RUNTIME,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG,
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL:
process.env.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL,
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN:
process.env.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN,
AGENT_SANDBOX_DISK_MB: process.env.AGENT_SANDBOX_DISK_MB,
AGENT_SANDBOX_PROXY_SECRET: process.env.AGENT_SANDBOX_PROXY_SECRET,
AGENT_SANDBOX_PROXY_URL: process.env.AGENT_SANDBOX_PROXY_URL,
......@@ -66,6 +70,14 @@ describe('sandbox provider config', () => {
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG
);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL
);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN
);
vi.stubEnv('AGENT_SANDBOX_DISK_MB', originalEnv.AGENT_SANDBOX_DISK_MB);
vi.stubEnv(
'AGENT_SANDBOX_WS_MAX_MESSAGE_BYTES',
......@@ -196,22 +208,6 @@ describe('sandbox provider config', () => {
});
});
it('requires sealos runtime image when runtime adapter config is requested', async () => {
vi.stubEnv('AGENT_SANDBOX_SEALOS_BASEURL', 'https://devbox.example.com');
vi.stubEnv('AGENT_SANDBOX_SEALOS_TOKEN', 'sealos-token');
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', undefined);
const { getSandboxAdapterConfig } = await loadSandboxConfigModule();
expect(() =>
getSandboxAdapterConfig({
provider: 'sealosdevbox',
runtime: true,
sessionId: 'session-1'
})
).toThrow('AGENT_SANDBOX_SEALOS_IMAGE is required for sealosdevbox provider');
});
it('normalizes missing provider env values before validation', async () => {
vi.resetModules();
vi.doMock('@fastgpt/service/env', () => ({
......@@ -243,30 +239,6 @@ describe('sandbox provider config', () => {
}
});
it('allows empty proxy secret before agent sandbox credentials are configured', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://opensandbox.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', '');
vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', '');
vi.resetModules();
const { serviceEnv } = await import('@fastgpt/service/env');
expect(serviceEnv.AGENT_SANDBOX_PROXY_SECRET).toBeUndefined();
});
it('rejects short proxy secret when agent sandbox is configured', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://opensandbox.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'opensandbox-api-key');
vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', 'short');
vi.resetModules();
await expect(import('@fastgpt/service/env')).rejects.toThrow(
'Invalid environment variables. Please check: AGENT_SANDBOX_PROXY_SECRET'
);
});
it('parses opensandbox config and runtime create config from env', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://opensandbox.local');
......@@ -274,6 +246,8 @@ describe('sandbox provider config', () => {
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_RUNTIME', 'docker');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'fastgpt-agent-sandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'test');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL', 'http://volume-manager.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN', 'volume-token');
const { getSandboxAdapterConfig } = await loadSandboxConfigModule();
......
......@@ -32,12 +32,12 @@ describe('sandbox runtime profile', () => {
});
it('uses fixed /workspace as opensandbox work directory', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'runtime-image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'stable');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
const runtimeProfile = getSandboxRuntimeProfile();
const runtimeProfile = getSandboxRuntimeProfile('opensandbox');
expect(runtimeProfile).toMatchObject({
provider: 'opensandbox',
......@@ -52,11 +52,11 @@ describe('sandbox runtime profile', () => {
});
it('uses devbox defaults for sealosdevbox provider', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'sealosdevbox');
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', 'runtime/fastgpt:stable');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
const runtimeProfile = getSandboxRuntimeProfile();
const runtimeProfile = getSandboxRuntimeProfile('sealosdevbox');
expect(runtimeProfile).toMatchObject({
provider: 'sealosdevbox',
......@@ -71,32 +71,21 @@ describe('sandbox runtime profile', () => {
});
it('uses sealos work directory from env', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'sealosdevbox');
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_SEALOS_WORK_DIRECTORY', '/custom/devbox/workspace');
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', 'runtime/fastgpt:stable');
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
expect(getSandboxRuntimeProfile()).toMatchObject({
expect(getSandboxRuntimeProfile('sealosdevbox')).toMatchObject({
provider: 'sealosdevbox',
workDirectory: '/custom/devbox/workspace',
entrypoint: ''
});
});
it('requires sealos runtime image when building create config', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'sealosdevbox');
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', undefined);
const { getSandboxRuntimeProfile } = await loadSandboxRuntimeProfileModule();
const runtimeProfile = getSandboxRuntimeProfile();
expect(() => runtimeProfile.buildConfig()).toThrow(
'AGENT_SANDBOX_SEALOS_IMAGE is required for sealosdevbox provider'
);
});
it('builds provider-specific create config through runtime profile', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_SEALOS_WORK_DIRECTORY', '/custom/devbox/workspace');
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', 'runtime/fastgpt:stable');
......
......@@ -14,9 +14,8 @@ describe('sandbox volume config', () => {
it('reads volume-manager configuration from service env', async () => {
vi.doMock('@fastgpt/service/env', () => ({
serviceEnv: {
AGENT_SANDBOX_ENABLE_VOLUME: true,
AGENT_SANDBOX_VOLUME_MANAGER_URL: 'http://volume-manager.local',
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: 'volume-token'
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL: 'http://volume-manager.local',
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN: 'volume-token'
}
}));
......
......@@ -145,7 +145,7 @@ describe('sandbox volume service', () => {
volumeConfigMock.config.url = '';
await expect(getSessionVolumeConfig('session-1')).rejects.toThrow(
'AGENT_SANDBOX_VOLUME_MANAGER_URL is required'
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL is required'
);
});
......
......@@ -8,6 +8,10 @@ import {
import type { SandboxClient } from '@fastgpt/service/core/ai/sandbox/interface/runtime';
import type { DirectoryEntry, FileInfo, FileReadResult } from '@fastgpt-sdk/sandbox-adapter';
vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/provider/runtimeProfile', () => ({
getSandboxRuntimeProfile: () => ({ workDirectory: '/workspace' })
}));
// ─── helpers ───────────────────────────────────────────────────────────────
function makeProvider(
......
......@@ -48,6 +48,7 @@ describe('getChatItems', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: getNanoid(),
......@@ -189,6 +190,7 @@ describe('getChatItems', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: getNanoid(),
......@@ -271,6 +273,7 @@ describe('getChatItems', () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: otherChatId,
dataId: getNanoid(),
......@@ -734,6 +737,7 @@ describe('getChatItems', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'ai-data-id',
......@@ -744,6 +748,7 @@ describe('getChatItems', () => {
await MongoChatItemResponse.create([
{
teamId: testUser.teamId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
chatItemDataId: aiItem.dataId,
......@@ -758,6 +763,7 @@ describe('getChatItems', () => {
},
{
teamId: testUser.teamId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
chatItemDataId: aiItem.dataId,
......@@ -799,6 +805,7 @@ describe('getChatItems', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'fallback-ai-data-id',
......@@ -816,6 +823,7 @@ describe('getChatItems', () => {
await MongoChatItemResponse.create({
teamId: testUser.teamId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
chatItemDataId: 'fallback-ai-data-id',
......@@ -844,6 +852,7 @@ describe('getChatItems', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'empty-inline-ai-data-id',
......@@ -854,6 +863,7 @@ describe('getChatItems', () => {
await MongoChatItemResponse.create({
teamId: testUser.teamId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
chatItemDataId: 'empty-inline-ai-data-id',
......@@ -886,6 +896,7 @@ describe('getChatItems', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: aiDataId,
......@@ -911,6 +922,7 @@ describe('getChatItems', () => {
await MongoChatItemResponse.create([
{
teamId: testUser.teamId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
chatItemDataId: aiDataId,
......@@ -942,6 +954,7 @@ describe('getChatItems', () => {
},
{
teamId: testUser.teamId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
chatItemDataId: aiDataId,
......@@ -966,6 +979,7 @@ describe('getChatItems', () => {
},
{
teamId: testUser.teamId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
chatItemDataId: aiDataId,
......@@ -1048,6 +1062,7 @@ describe('updateChatFeedbackCount', () => {
chatId,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
source: ChatSourceEnum.online
});
......@@ -1066,6 +1081,7 @@ describe('updateChatFeedbackCount', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: getNanoid(),
......
......@@ -40,7 +40,7 @@ const createChatTree = async ({
}) => {
const teamId = '65f000000000000000000001';
const tmbId = '65f000000000000000000002';
const chatSource = legacy ? {} : { sourceType };
const chatSource = { sourceType };
await MongoChat.create({
...chatSource,
......
......@@ -7,7 +7,8 @@ const base = {
teamId: '654a4107c32f3bf5f998452f',
tmbId: '654a4107c32f3bf5f9984530',
appId: '67e0d5535c02d1d5cdede71f',
chatId: 'interactive-chat-id'
chatId: 'interactive-chat-id',
sourceType: ChatSourceTypeEnum.app
};
const chatSource = {
......
......@@ -443,6 +443,7 @@ describe('pushChatRecords', () => {
}).lean();
await MongoChatItemResponse.create({
teamId: testTeamId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
chatId: props.chatId,
chatItemDataId: aiItem?.dataId,
......@@ -555,6 +556,7 @@ describe('pushChatRecords', () => {
chatId: props.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: props.source,
chatGenerateStatus: ChatGenerateStatusEnum.generating,
hasBeenRead: false
......@@ -563,6 +565,7 @@ describe('pushChatRecords', () => {
{
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
chatId: props.chatId,
dataId: responseChatItemId,
......@@ -572,6 +575,7 @@ describe('pushChatRecords', () => {
{
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
chatId: props.chatId,
dataId: responseChatItemId,
......@@ -611,6 +615,7 @@ describe('pushChatRecords', () => {
chatId: props.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: props.source,
chatGenerateStatus: ChatGenerateStatusEnum.generating,
hasBeenRead: false
......@@ -618,6 +623,7 @@ describe('pushChatRecords', () => {
await MongoChatItem.create({
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
chatId: props.chatId,
dataId: responseChatItemId,
......@@ -702,6 +708,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.Human,
value: [
......@@ -739,6 +746,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [
......@@ -776,6 +784,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'data-id-1',
......@@ -853,6 +862,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'data-id-1',
......@@ -942,6 +952,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'data-id-1',
......@@ -1068,6 +1079,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'plan-ask-data-id',
......@@ -1125,6 +1137,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'plan-ask-data-id',
......@@ -1171,6 +1184,7 @@ describe('pushChatRecords', () => {
chatId: props.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
source: props.source,
title: 'Test Chat'
......@@ -1180,6 +1194,7 @@ describe('pushChatRecords', () => {
chatId: props.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.Human,
dataId: 'prepared-round-data-id',
......@@ -1193,6 +1208,7 @@ describe('pushChatRecords', () => {
chatId: props.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'prepared-round-data-id',
......@@ -1267,6 +1283,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'data-id-1',
......@@ -1313,6 +1330,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'data-id-1',
......@@ -1381,6 +1399,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'data-id-1',
......@@ -1438,6 +1457,7 @@ describe('pushChatRecords', () => {
chatId: 'test-chat-id',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
dataId: 'data-id-1',
......@@ -1460,6 +1480,7 @@ describe('pushChatRecords', () => {
// Create an existing response
await MongoChatItemResponse.create({
teamId: testTeamId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
chatId: 'test-chat-id',
chatItemDataId: 'data-id-1',
......@@ -1474,6 +1495,7 @@ describe('pushChatRecords', () => {
});
await MongoChatItemResponse.create({
teamId: testTeamId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
chatId: 'test-chat-id',
chatItemDataId: 'data-id-1',
......
......@@ -15,51 +15,42 @@ const hasIndex = (
);
});
const findIndex = (
indexes: ReturnType<typeof MongoChat.schema.indexes>,
keys: Record<string, 1 | -1>
) => indexes.find(([indexKeys]) => JSON.stringify(indexKeys) === JSON.stringify(keys));
describe('chat schema indexes', () => {
it('keeps sourceType optional without silently defaulting new writes to app', () => {
it('requires sourceType for new chat writes without silently defaulting to app', () => {
for (const schema of [MongoChat.schema, MongoChatItem.schema, MongoChatItemResponse.schema]) {
const sourceTypePath = schema.path('sourceType');
expect(sourceTypePath?.options.required).toBeUndefined();
expect(sourceTypePath?.options.required).toBe(true);
expect(sourceTypePath?.options.default).toBeUndefined();
}
});
it('declares source-aware unique chat identity index while keeping legacy app index', () => {
it('declares source-aware unique chat identity index', () => {
const indexes = MongoChat.schema.indexes();
const sourceAwareIndex = findIndex(indexes, { sourceType: 1, appId: 1, chatId: 1 });
expect(hasIndex(indexes, { appId: 1, chatId: 1 }, { unique: true })).toBe(true);
expect(
hasIndex(
indexes,
{
sourceType: 1,
appId: 1,
chatId: 1
},
{
unique: true,
name: 'sourceType_1_appId_1_chatId_1'
}
)
).toBe(true);
expect(sourceAwareIndex?.[1]?.unique).toBe(true);
});
it('declares source-aware chat item read and pagination indexes', () => {
it('keeps legacy chat item read and pagination indexes', () => {
const indexes = MongoChatItem.schema.indexes();
expect(hasIndex(indexes, { sourceType: 1, appId: 1, chatId: 1, dataId: 1 })).toBe(true);
expect(hasIndex(indexes, { sourceType: 1, appId: 1, chatId: 1, deleteTime: 1 })).toBe(true);
expect(hasIndex(indexes, { sourceType: 1, appId: 1, chatId: 1, _id: -1 })).toBe(true);
expect(hasIndex(indexes, { sourceType: 1, appId: 1, chatId: 1, obj: 1, _id: -1 })).toBe(true);
expect(hasIndex(indexes, { appId: 1, chatId: 1, dataId: 1 })).toBe(true);
expect(hasIndex(indexes, { appId: 1, chatId: 1, deleteTime: 1 })).toBe(true);
expect(hasIndex(indexes, { appId: 1, chatId: 1, _id: -1 })).toBe(true);
expect(hasIndex(indexes, { appId: 1, chatId: 1, obj: 1, _id: -1 })).toBe(true);
});
it('declares source-aware node response lookup index', () => {
it('keeps legacy node response lookup index', () => {
const indexes = MongoChatItemResponse.schema.indexes();
expect(
hasIndex(indexes, {
sourceType: 1,
appId: 1,
chatId: 1,
chatItemDataId: 1,
......
......@@ -39,6 +39,7 @@ const createChat = (override: Record<string, unknown> = {}) =>
chatId: base.chatId,
teamId: base.teamId,
tmbId: base.tmbId,
sourceType: base.sourceType,
appId: base.appId,
source: ChatSourceEnum.online,
...override
......
......@@ -87,6 +87,7 @@ describe('chat dataId validation', () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'same-round-id',
......@@ -112,6 +113,7 @@ describe('chat dataId validation', () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'existing-ai',
......@@ -166,6 +168,7 @@ describe('chat dataId validation', () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'existing-human',
......@@ -196,6 +199,7 @@ describe('chat dataId validation', () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'existing-ai',
......
......@@ -185,6 +185,7 @@ describe('prepare chat round', () => {
chatId: params.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: params.source,
sourceName: 'original-source-name',
updateTime: originalUpdateTime,
......@@ -208,6 +209,7 @@ describe('prepare chat round', () => {
await MongoChatItem.create({
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
chatId: params.chatId,
dataId: 'duplicated-ai-data-id',
......@@ -245,11 +247,13 @@ describe('prepare chat round', () => {
chatId: params.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: params.source
});
await MongoChatItem.create({
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
chatId: params.chatId,
dataId: 'previous-ai-data-id',
......@@ -386,6 +390,7 @@ describe('prepare chat round', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: params.source,
title: 'Manual Topic',
customTitle: 'Manual Topic'
......
......@@ -9,11 +9,13 @@ import { ReadFileTooData } from '@fastgpt/service/core/workflow/dispatch/ai/tool
const {
getSandboxToolInfoMock,
getSandboxRuntimeProfileMock,
prepareSandboxToolRuntimeMock,
runAgentSandboxEntrypointMock,
withAgentSandboxInitLeaseMock
} = vi.hoisted(() => ({
getSandboxToolInfoMock: vi.fn(),
getSandboxRuntimeProfileMock: vi.fn(),
prepareSandboxToolRuntimeMock: vi.fn(),
runAgentSandboxEntrypointMock: vi.fn(),
withAgentSandboxInitLeaseMock: vi.fn(async ({ fn }: { fn: () => Promise<unknown> }) => fn())
......@@ -35,6 +37,7 @@ vi.mock('@fastgpt/service/core/ai/sandbox/interface/runtime', async (importOrigi
await importOriginal<typeof import('@fastgpt/service/core/ai/sandbox/interface/runtime')>();
return {
...original,
getSandboxRuntimeProfile: getSandboxRuntimeProfileMock,
runAgentSandboxEntrypoint: runAgentSandboxEntrypointMock,
withAgentSandboxInitLease: withAgentSandboxInitLeaseMock
};
......@@ -55,6 +58,7 @@ describe('useToolCatalog', () => {
beforeEach(() => {
vi.clearAllMocks();
getSandboxToolInfoMock.mockReturnValue(undefined);
getSandboxRuntimeProfileMock.mockReturnValue({ workDirectory: '/workspace' });
prepareSandboxToolRuntimeMock.mockResolvedValue({
provider: {
execute: vi.fn()
......
......@@ -74,11 +74,18 @@ const makeDispatchFlowResponse = (
} as DispatchFlowResponse;
};
const makeVariableState = () =>
const makeVariableState = (runtimeVariables: Record<string, unknown> = {}) =>
({
clone: () => makeVariableState(),
toRuntimeRecord: () => ({}),
toStoreRecord: () => ({})
clone: vi.fn(() => makeVariableState(runtimeVariables)),
get: vi.fn((key: string) => runtimeVariables[key]),
set: vi.fn(async (key: string, value: unknown) => {
runtimeVariables[key] = value;
return value;
}),
getStoreValue: vi.fn((key: string) => runtimeVariables[key]),
getFileStoreValueByRuntimeUrl: vi.fn(),
toRuntimeRecord: () => ({ ...runtimeVariables }),
toStoreRecord: () => ({ ...runtimeVariables })
}) as any;
const makeProps = (override: Record<string, any> = {}) => {
......@@ -232,4 +239,76 @@ describe('dispatchParallelRun', () => {
expect(runWorkflowMock.mock.calls[0][0].nodeResponseParentId).toBe('parallelRun1_task_0');
});
it('成功任务结束后把 clone 中的全局变量提交回父状态', async () => {
const taskVariableState = makeVariableState({ count: 2 });
const parentVariableState = {
...makeVariableState(),
clone: vi.fn(() => taskVariableState),
set: vi.fn()
};
runWorkflowMock.mockResolvedValue(
makeDispatchFlowResponse({
nodeResponses: [
makeResponseItem('nestedEnd', {
moduleType: FlowNodeTypeEnum.nestedEnd,
loopOutputValue: 'done'
})
]
})
);
await dispatchParallelRun(
makeProps({
variableState: parentVariableState
})
);
expect(parentVariableState.set).toHaveBeenCalledWith('count', 2);
});
it('失败 attempt 不提交全局变量更新,重试成功后只提交成功 attempt 的更新', async () => {
const failedClone = makeVariableState({ count: 1 });
const successClone = makeVariableState({ count: 2 });
const parentVariableState = {
...makeVariableState(),
clone: vi.fn().mockReturnValueOnce(failedClone).mockReturnValueOnce(successClone),
set: vi.fn()
};
runWorkflowMock
.mockResolvedValueOnce(
makeDispatchFlowResponse({
nodeResponses: [
makeResponseItem('failed-node', {
error: 'failed'
})
]
})
)
.mockResolvedValueOnce(
makeDispatchFlowResponse({
nodeResponses: [
makeResponseItem('nestedEnd', {
moduleType: FlowNodeTypeEnum.nestedEnd,
loopOutputValue: 'done'
})
]
})
);
await dispatchParallelRun(
makeProps({
params: {
loopInputArray: ['a'],
[NodeInputKeyEnum.childrenNodeIdList]: [],
[NodeInputKeyEnum.parallelRunMaxConcurrency]: 1,
[NodeInputKeyEnum.parallelRunMaxRetryTimes]: 1
},
variableState: parentVariableState
})
);
expect(parentVariableState.set).toHaveBeenCalledTimes(1);
expect(parentVariableState.set).toHaveBeenCalledWith('count', 2);
});
});
......@@ -11,9 +11,12 @@ const originalEnv = {
VITEST: process.env.VITEST,
NODE_ENV: process.env.NODE_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,
AGENT_SANDBOX_SEALOS_IMAGE: process.env.AGENT_SANDBOX_SEALOS_IMAGE,
AGENT_SANDBOX_E2B_API_KEY: process.env.AGENT_SANDBOX_E2B_API_KEY,
AGENT_SANDBOX_OPENSANDBOX_BASEURL: process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL,
AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY,
AGENT_SANDBOX_PROXY_URL: process.env.AGENT_SANDBOX_PROXY_URL
AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY
};
const importServiceEnv = async () => {
......@@ -32,15 +35,12 @@ describe('serviceEnv', () => {
vi.stubEnv('VITEST', originalEnv.VITEST);
vi.stubEnv('NODE_ENV', originalEnv.NODE_ENV);
vi.stubEnv('AGENT_SANDBOX_PROVIDER', originalEnv.AGENT_SANDBOX_PROVIDER);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_BASEURL',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_BASEURL
);
vi.stubEnv(
'AGENT_SANDBOX_OPENSANDBOX_API_KEY',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_API_KEY
);
vi.stubEnv('AGENT_SANDBOX_PROXY_URL', originalEnv.AGENT_SANDBOX_PROXY_URL);
vi.stubEnv('AGENT_SANDBOX_SEALOS_BASEURL', originalEnv.AGENT_SANDBOX_SEALOS_BASEURL);
vi.stubEnv('AGENT_SANDBOX_SEALOS_TOKEN', originalEnv.AGENT_SANDBOX_SEALOS_TOKEN);
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', originalEnv.AGENT_SANDBOX_SEALOS_IMAGE);
vi.stubEnv('AGENT_SANDBOX_E2B_API_KEY', originalEnv.AGENT_SANDBOX_E2B_API_KEY);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', originalEnv.AGENT_SANDBOX_OPENSANDBOX_BASEURL);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', originalEnv.AGENT_SANDBOX_OPENSANDBOX_API_KEY);
});
it('validates SYSTEM_MAX_STRING_LENGTH_M during service env init', async () => {
......@@ -127,32 +127,23 @@ describe('serviceEnv', () => {
expect(customEnv.serviceEnv.AGENT_SANDBOX_DISK_MB).toBe(333);
});
it('未启用 Agent Sandbox 时允许 AGENT_SANDBOX_PROXY_URL 为空', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret);
vi.stubEnv('VITEST', 'true');
vi.stubEnv('AGENT_SANDBOX_PROVIDER', '');
vi.stubEnv('AGENT_SANDBOX_PROXY_URL', '');
await expect(importServiceEnv()).resolves.toBeDefined();
});
it('启用 opensandbox 时要求配置 AGENT_SANDBOX_PROXY_URL', async () => {
it('配置 sealosdevbox 后缺少运行镜像会阻止启动', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret);
vi.stubEnv('VITEST', 'true');
vi.stubEnv('NODE_ENV', 'development');
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://mock-opensandbox.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'mock-opensandbox-api-key');
vi.stubEnv('AGENT_SANDBOX_PROXY_URL', '');
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'sealosdevbox');
vi.stubEnv('AGENT_SANDBOX_SEALOS_BASEURL', 'http://mock-sealos.local');
vi.stubEnv('AGENT_SANDBOX_SEALOS_TOKEN', 'mock-sealos-token');
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', '');
await expect(importServiceEnv()).rejects.toThrow('AGENT_SANDBOX_PROXY_URL is required');
await expect(importServiceEnv()).rejects.toThrow(
'AGENT_SANDBOX_SEALOS_IMAGE are required when AGENT_SANDBOX_PROVIDER is sealosdevbox'
);
});
it('启用 opensandbox 时要求 AGENT_SANDBOX_PROXY_URL 是 WebSocket 地址', async () => {
it('启用 Agent Sandbox 时不要求共享服务配置 app proxy 环境变量', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret);
......@@ -161,8 +152,11 @@ describe('serviceEnv', () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://mock-opensandbox.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'mock-opensandbox-api-key');
vi.stubEnv('AGENT_SANDBOX_PROXY_URL', 'http://localhost:1006');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL', 'http://mock-volume-manager.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN', 'mock-volume-manager-token');
vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', '');
vi.stubEnv('AGENT_SANDBOX_PROXY_URL', '');
await expect(importServiceEnv()).rejects.toThrow('AGENT_SANDBOX_PROXY_URL');
await expect(importServiceEnv()).resolves.toBeDefined();
});
});
import { describe, expect, it } from 'vitest';
import { getAgentSandboxMissingRequiredEnvKeys } from '@fastgpt/service/env.util';
describe('env util', () => {
it('requires opensandbox volume manager env when opensandbox provider is enabled', () => {
expect(
getAgentSandboxMissingRequiredEnvKeys({
AGENT_SANDBOX_PROVIDER: 'opensandbox',
AGENT_SANDBOX_OPENSANDBOX_BASEURL: 'http://opensandbox.local',
AGENT_SANDBOX_OPENSANDBOX_API_KEY: 'opensandbox-key'
} as NodeJS.ProcessEnv)
).toEqual([
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL',
'AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN'
]);
});
it('does not require opensandbox volume manager env for other providers', () => {
expect(
getAgentSandboxMissingRequiredEnvKeys({
AGENT_SANDBOX_PROVIDER: 'e2b',
AGENT_SANDBOX_E2B_API_KEY: 'e2b-key'
} as NodeJS.ProcessEnv)
).toEqual([]);
});
});
......@@ -21,9 +21,7 @@ export default defineConfig({
process.env.FILE_TOKEN_KEY ??
'bfd697e7e798f75deaf2d31210bc93a2e41ad4eed9e7831071d77821b7b97cff',
AES256_SECRET_KEY: process.env.AES256_SECRET_KEY ?? 'fastgpt_test_aes256_secret_key',
INVOKE_TOKEN_SECRET:
process.env.INVOKE_TOKEN_SECRET ?? 'fastgpt_test_invoke_token_secret_32',
AGENT_SANDBOX_PROVIDER: 'opensandbox'
INVOKE_TOKEN_SECRET: process.env.INVOKE_TOKEN_SECRET ?? 'fastgpt_test_invoke_token_secret_32'
},
coverage: {
enabled: true,
......
......@@ -169,6 +169,7 @@
"parallel_full_results": "Full Results",
"parallel_full_results_desc": "Array of the same length as the input, each item is {success, message, data}: on success success=true, message is empty, data is the output; on failure success=false, message contains the error, data is null.",
"parallel_run": "Parallel Run",
"parallel_run_end_intro": "Select a variable to use as the batch execution result output.",
"parallel_run_execution_logic": "Execution Logic",
"parallel_run_max_concurrency": "Max Concurrency",
"parallel_run_max_concurrency_tip": "Maximum number of tasks running in parallel (range: 1 to the upper limit, default 5).",
......
......@@ -169,6 +169,7 @@
"parallel_full_results": "完整结果",
"parallel_full_results_desc": "与输入数组等长的结果数组,每项形如 {success, message, data}:成功时 success=true、message 为空、data 为输出值;失败时 success=false、message 为错误信息、data 为 null。",
"parallel_run": "并行执行",
"parallel_run_end_intro": "选择变量,作为批量执行的结果输出。",
"parallel_run_execution_logic": "执行逻辑",
"parallel_run_max_concurrency": "最大并发数",
"parallel_run_max_concurrency_tip": "同时并行执行的最大任务数,范围 1~上限值(默认 5)。",
......
......@@ -169,6 +169,7 @@
"parallel_full_results": "完整結果",
"parallel_full_results_desc": "與輸入陣列等長的結果陣列,每項形如 {success, message, data}:成功時 success=true、message 為空、data 為輸出值;失敗時 success=false、message 為錯誤訊息、data 為 null。",
"parallel_run": "並行執行",
"parallel_run_end_intro": "選擇變數,作為批量執行的結果輸出。",
"parallel_run_execution_logic": "執行邏輯",
"parallel_run_max_concurrency": "最大並發數",
"parallel_run_max_concurrency_tip": "同時並行執行的最大任務數,範圍 1~上限值(預設 5)。",
......
Subproject commit 7a41268519c8dab4f1f7fa55edf93869ee507bae
Subproject commit 120262a5c3bdf2b0a7b7d16a851ad200dfe6edcb
......@@ -55,10 +55,8 @@ AGENT_SANDBOX_OPENSANDBOX_RUNTIME=docker
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO=registry.cn-hangzhou.aliyuncs.com/fastgpt/fastgpt-agent-sandbox
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG=v0.1
AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY=true
# Volume 持久化配置(opensandbox provider 下可选)
AGENT_SANDBOX_ENABLE_VOLUME=true
AGENT_SANDBOX_VOLUME_MANAGER_URL=http://localhost:3005
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN=vmtoken
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_URL=http://localhost:3005
AGENT_SANDBOX_OPENSANDBOX_VOLUME_MANAGER_TOKEN=vmtoken
# E2B 配置(PROVIDER=e2b 时生效)
AGENT_SANDBOX_E2B_API_KEY=
......
......@@ -33,7 +33,8 @@ export async function registerNodeInstrumentation() {
{ configureLogger, getLogger, LogCategories },
{ configureMetrics },
{ configureTracing },
{ InitialErrorEnum }
{ InitialErrorEnum },
{ validateAgentSandboxProxyEnv }
] = await Promise.all([
import('@fastgpt/service/common/mongo/init'),
import('@fastgpt/service/common/mongo/index'),
......@@ -55,7 +56,8 @@ export async function registerNodeInstrumentation() {
import('@fastgpt/service/common/logger'),
import('@fastgpt/service/common/metrics'),
import('@fastgpt/service/common/tracing'),
import('@fastgpt/service/common/system/constants')
import('@fastgpt/service/common/system/constants'),
import('@fastgpt/service/env.util')
]);
await Promise.all([
......@@ -76,6 +78,11 @@ export async function registerNodeInstrumentation() {
action: () => initGlobalVariables(),
logger
});
await runInitializationStep({
step: 'validate-agent-sandbox-proxy-env',
action: () => validateAgentSandboxProxyEnv(),
logger
});
await Promise.all([
runInitializationStep({
......
const formInputSelector = 'input, textarea, select';
const contentEditableSelector = '[contenteditable]';
const getElementFromNode = (target?: EventTarget | Node | null): Element | null => {
if (!target) return null;
const node = target as Node & {
nodeType?: number;
parentElement?: Element | null;
};
if (node.nodeType === 1) {
return node as unknown as Element;
}
return node.parentElement ?? null;
};
const getClassName = (element: Element) => {
const className = (element as { className?: unknown }).className;
if (typeof className === 'string') return className.toLowerCase();
if (
className &&
typeof className === 'object' &&
typeof (className as { baseVal?: unknown }).baseVal === 'string'
) {
return (className as { baseVal: string }).baseVal.toLowerCase();
}
return '';
};
const isEditableElement = (target?: EventTarget | Node | null) => {
const element = getElementFromNode(target);
if (!element) return false;
if (element.closest(formInputSelector)) return true;
const contentEditableElement = element.closest(contentEditableSelector);
if (contentEditableElement) {
const editableValue = contentEditableElement.getAttribute('contenteditable');
if (editableValue !== 'false') return true;
}
const className = getClassName(element);
return className.includes('prompteditor') || className.includes('contenteditable');
};
/**
* 判断工作流画布快捷键是否应让文本编辑区优先处理。
* ahooks 的全局 keydown 回调在 Lexical/contenteditable 选区场景下可能拿到 body 作为
* target,因此需要同时检查事件目标、当前焦点和 Selection 锚点。
*/
export const isWorkflowShortcutInputtingTarget = (target?: EventTarget | Node | null) => {
if (isEditableElement(target)) return true;
if (typeof document !== 'undefined' && isEditableElement(document.activeElement)) return true;
const selection =
typeof window !== 'undefined' && typeof window.getSelection === 'function'
? window.getSelection()
: null;
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false;
return isEditableElement(selection.anchorNode) || isEditableElement(selection.focusNode);
};
......@@ -12,6 +12,7 @@ import { WorkflowBufferDataContext } from '../../context/workflowInitContext';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { WorkflowUIContext } from '../../context/workflowUIContext';
import { isWorkflowShortcutInputtingTarget } from './keyboard';
export const useKeyboard = () => {
const { t } = useTranslation();
......@@ -31,17 +32,8 @@ export const useKeyboard = () => {
const isDowningCtrl = useKeyPress(['Meta', 'Control']);
const hasInputtingElement = useCallback(() => {
const activeElement = document.activeElement;
if (activeElement) {
const tagName = activeElement.tagName.toLowerCase();
const className = activeElement.className.toLowerCase();
if (tagName === 'input' || tagName === 'textarea') return true;
if (className.includes('prompteditor')) return true;
}
return false;
const hasInputtingElement = useCallback((event?: KeyboardEvent) => {
return isWorkflowShortcutInputtingTarget(event?.target);
}, []);
const onCopy = useCallback(async () => {
......@@ -123,7 +115,7 @@ export const useKeyboard = () => {
//@ts-ignore
.concat(newNodes)
);
} catch (error) {}
} catch {}
}, [
computedNewNodeName,
hasInputtingElement,
......@@ -136,10 +128,12 @@ export const useKeyboard = () => {
useKeyPressEffect(['ctrl.c', 'meta.c'], (e) => {
if (!mouseInCanvas) return;
if (hasInputtingElement(e)) return;
onCopy();
});
useKeyPressEffect(['ctrl.v', 'meta.v'], (e) => {
if (!mouseInCanvas) return;
if (hasInputtingElement(e)) return;
onPaste();
});
useKeyPressEffect(['ctrl.s', 'meta.s'], (e) => {
......
......@@ -41,9 +41,9 @@ const NodeLoopEnd = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const parallelRunIntro = useMemoEnhance(() => {
const parentNode = getNodeById(parentNodeId);
return parentNode?.flowNodeType === FlowNodeTypeEnum.parallelRun
? 'workflow:parallel_run_end_intro'
? t('workflow:parallel_run_end_intro')
: undefined;
}, [getNodeById, parentNodeId]);
}, [getNodeById, parentNodeId, t]);
// Get loopEnd input value type
const valueType = useMemo(() => {
......
......@@ -56,10 +56,6 @@ export const getSandboxProxyWsUrl = ({
const { agentSandboxProxyUrl = '' } = useSystemStore.getState().feConfigs;
const proxyBaseUrl = agentSandboxProxyUrl.replace(/\/+$/, '');
if (!proxyBaseUrl) {
throw new Error('AGENT_SANDBOX_PROXY_URL is required but not configured');
}
return `${proxyBaseUrl}/${channel}?ticket=${encodeURIComponent(ticket)}`;
};
......
......@@ -15,6 +15,7 @@ import z from 'zod';
* ============================================================================ */
const DEFAULT_SAMPLE_LIMIT = 20;
const CLEANUP_DUPLICATE_CHATS_INDEX_NAME = 'idx_cleanup_duplicate_chats_appId_chatId';
const CleanupDuplicateChatsBodySchema = z
.object({
......@@ -69,9 +70,7 @@ const CleanupDuplicateChatsResponseSchema = z.object({
sampleLimit: z.number().int().nonnegative().meta({ description: '返回样本数量限制' }),
samples: z.array(DuplicateChatGroupSampleSchema).meta({ description: '重复组样本' })
});
export type CleanupDuplicateChatsResponseType = z.infer<
typeof CleanupDuplicateChatsResponseSchema
>;
export type CleanupDuplicateChatsResponseType = z.infer<typeof CleanupDuplicateChatsResponseSchema>;
type DuplicateKeyGroup = {
_id: {
......@@ -93,6 +92,30 @@ const stringifyId = (value: unknown) => {
return String(value);
};
/**
* 为重复会话头清理创建临时查询索引。
*
* 历史库里唯一索引可能因为重复数据没有建成功,迁移前先补一个非唯一索引,
* 避免全表扫描;如果已经存在同 key 索引(无论唯一/非唯一),直接复用。
*/
const ensureDuplicateChatCleanupIndex = async () => {
const indexes = await MongoChat.collection.indexes();
const hasAppIdChatIdIndex = indexes.some((index) => {
const keys = Object.keys(index.key);
return keys.length === 2 && index.key.appId === 1 && index.key.chatId === 1;
});
if (hasAppIdChatIdIndex) return;
await MongoChat.collection.createIndex(
{ appId: 1, chatId: 1 },
{
name: CLEANUP_DUPLICATE_CHATS_INDEX_NAME,
background: true
}
);
};
const findDuplicateChatGroups = () =>
MongoChat.aggregate<DuplicateKeyGroup>(
[
......@@ -129,6 +152,8 @@ const findDuplicateChatDocs = (group: DuplicateKeyGroup) =>
export async function runCleanupDuplicateChatsMigration(
params: CleanupDuplicateChatsBodyType
): Promise<CleanupDuplicateChatsResponseType> {
await ensureDuplicateChatCleanupIndex();
const duplicateGroups = await findDuplicateChatGroups();
let duplicateDocumentCount = 0;
......
......@@ -14,7 +14,7 @@ import {
ChatItemResponseCollectionName
} from '@fastgpt/service/core/chat/constants';
import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema';
import { type ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSourceTypeEnum, type ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { AppLogKeysEnum } from '@fastgpt/global/core/app/logs/constants';
import { sanitizeCsvField } from '@fastgpt/service/common/file/csv';
import { AppReadChatLogPerVal } from '@fastgpt/global/support/permission/app/constant';
......@@ -31,6 +31,10 @@ import { ExportChatLogsBodySchema } from '@fastgpt/global/openapi/core/app/log/a
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
const logger = getLogger(LogCategories.MODULE.APP.LOGS);
const appChatSourceMatch = {
$or: [{ sourceType: ChatSourceTypeEnum.app }, { sourceType: { $exists: false } }]
};
const formatJsonString = (data: any) => {
if (data == null) return '';
if (typeof data === 'object') {
......@@ -99,6 +103,7 @@ async function handler(req: ApiRequestProps, res: NextApiResponse) {
const where = {
appId: new Types.ObjectId(appId),
$and: [appChatSourceMatch],
// Feedback type filtering (BEFORE pagination for performance)
...(feedbackType === 'has_feedback' &&
!unreadOnly && {
......@@ -174,7 +179,16 @@ async function handler(req: ApiRequestProps, res: NextApiResponse) {
{
$match: {
$expr: {
$and: [{ $eq: ['$appId', '$$appId'] }, { $eq: ['$chatId', '$$chatId'] }]
$and: [
{ $eq: ['$appId', '$$appId'] },
{ $eq: ['$chatId', '$$chatId'] },
{
$or: [
{ $eq: ['$sourceType', ChatSourceTypeEnum.app] },
{ $eq: [{ $type: '$sourceType' }, 'missing'] }
]
}
]
}
}
},
......@@ -248,7 +262,16 @@ async function handler(req: ApiRequestProps, res: NextApiResponse) {
{
$match: {
$expr: {
$and: [{ $eq: ['$appId', '$$appId'] }, { $eq: ['$chatId', '$$chatId'] }]
$and: [
{ $eq: ['$appId', '$$appId'] },
{ $eq: ['$chatId', '$$chatId'] },
{
$or: [
{ $eq: ['$sourceType', ChatSourceTypeEnum.app] },
{ $eq: [{ $type: '$sourceType' }, 'missing'] }
]
}
]
}
}
},
......
......@@ -16,6 +16,11 @@ import {
} from '@fastgpt/global/openapi/core/app/log/api';
import { DEFAULT_USER_AVATAR } from '@fastgpt/global/common/system/constants';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
const appChatSourceMatch = {
$or: [{ sourceType: ChatSourceTypeEnum.app }, { sourceType: { $exists: false } }]
};
async function handler(req: ApiRequestProps): Promise<GetLogUsersResponse> {
const {
......@@ -42,6 +47,7 @@ async function handler(req: ApiRequestProps): Promise<GetLogUsersResponse> {
{
$match: {
appId: new Types.ObjectId(appId),
...appChatSourceMatch,
updateTime: {
$gte: new Date(dateStart),
$lte: new Date(dateEnd)
......
......@@ -22,6 +22,11 @@ import {
type getAppChatLogsResponseType
} from '@fastgpt/global/openapi/core/app/log/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
const appChatSourceMatch = {
$or: [{ sourceType: ChatSourceTypeEnum.app }, { sourceType: { $exists: false } }]
};
async function handler(req: ApiRequestProps): Promise<getAppChatLogsResponseType> {
const {
......@@ -56,6 +61,7 @@ async function handler(req: ApiRequestProps): Promise<getAppChatLogsResponseType
const where = {
appId: new Types.ObjectId(appId),
$and: [appChatSourceMatch],
// Feedback type filtering (BEFORE pagination for performance)
...(feedbackType === 'has_feedback' &&
!unreadOnly && {
......@@ -132,7 +138,16 @@ async function handler(req: ApiRequestProps): Promise<getAppChatLogsResponseType
{
$match: {
$expr: {
$and: [{ $eq: ['$appId', '$$appId'] }, { $eq: ['$chatId', '$$chatId'] }]
$and: [
{ $eq: ['$appId', '$$appId'] },
{ $eq: ['$chatId', '$$chatId'] },
{
$or: [
{ $eq: ['$sourceType', ChatSourceTypeEnum.app] },
{ $eq: [{ $type: '$sourceType' }, 'missing'] }
]
}
]
}
}
},
......@@ -192,7 +207,16 @@ async function handler(req: ApiRequestProps): Promise<getAppChatLogsResponseType
{
$match: {
$expr: {
$and: [{ $eq: ['$appId', '$$appId'] }, { $eq: ['$chatId', '$$chatId'] }]
$and: [
{ $eq: ['$appId', '$$appId'] },
{ $eq: ['$chatId', '$$chatId'] },
{
$or: [
{ $eq: ['$sourceType', ChatSourceTypeEnum.app] },
{ $eq: [{ $type: '$sourceType' }, 'missing'] }
]
}
]
}
}
},
......
......@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
getAgentSandboxMaxFileBytes: vi.fn(),
getReadStream: vi.fn(),
getSandboxClient: vi.fn(),
getSandboxRuntimeProfile: vi.fn(),
resolveFormData: vi.fn(),
writeFiles: vi.fn()
}));
......@@ -38,6 +39,10 @@ vi.mock('@fastgpt/service/core/ai/sandbox/interface/runtime', () => ({
getSandboxClient: mocks.getSandboxClient
}));
vi.mock('@fastgpt/service/core/ai/sandbox/infrastructure/provider/runtimeProfile', () => ({
getSandboxRuntimeProfile: mocks.getSandboxRuntimeProfile
}));
import handler from '@/pages/api/core/ai/sandbox/upload';
const createReq = () =>
......@@ -51,6 +56,7 @@ describe('sandbox upload API', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getAgentSandboxMaxFileBytes.mockReturnValue(10 * 1024 * 1024);
mocks.getSandboxRuntimeProfile.mockReturnValue({ workDirectory: '/workspace' });
mocks.getReadStream.mockReturnValue(Readable.from([new Uint8Array([1, 2, 3])]));
mocks.resolveFormData.mockResolvedValue({
data: {
......
......@@ -21,7 +21,7 @@ import { MongoResourcePermission } from '@fastgpt/service/support/permission/sch
import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant';
import { MongoAppLogKeys } from '@fastgpt/service/core/app/logs/logkeysSchema';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSourceEnum, ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { MongoSystemTool } from '@fastgpt/service/core/plugin/tool/systemToolSchema';
// Mock dependencies for queue functionality
......@@ -45,10 +45,9 @@ vi.mock('@fastgpt/service/common/file/image/controller', () => ({
}));
// Import mocked modules for type access
import { getQueue, getWorker, QueueNames } from '@fastgpt/service/common/bullmq';
import { getQueue, QueueNames } from '@fastgpt/service/common/bullmq';
const mockGetQueue = vi.mocked(getQueue);
const mockGetWorker = vi.mocked(getWorker);
describe('App Delete Queue', () => {
beforeEach(() => {
......@@ -464,6 +463,7 @@ describe('App Delete Data Cleanup Verification', () => {
appId: appId,
teamId: teamId,
tmbId: rootUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
chatId: `test-chat-${timestamp}`,
title: 'Test Chat',
source: ChatSourceEnum.test,
......@@ -477,6 +477,7 @@ describe('App Delete Data Cleanup Verification', () => {
appId: appId,
teamId: teamId,
tmbId: rootUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
chatId: `test-chat-${timestamp}`,
time: timestamp,
obj: 'Human',
......@@ -490,6 +491,7 @@ describe('App Delete Data Cleanup Verification', () => {
appId: appId,
teamId: teamId,
tmbId: rootUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
chatItemId: `test-chat-item-${timestamp}`,
time: timestamp,
text: 'This is a test response',
......
......@@ -12,6 +12,7 @@ import type {
GetLogUsersBody,
GetLogUsersResponse
} from '@fastgpt/global/openapi/core/app/log/api';
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
type EmptyQuery = Record<string, never>;
......@@ -99,6 +100,7 @@ describe('getUsers API', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'Chat 1'
......@@ -108,6 +110,7 @@ describe('getUsers API', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'Chat 2'
......@@ -118,6 +121,7 @@ describe('getUsers API', () => {
teamId: testTeamId,
tmbId: testTmbId,
outLinkUid: 'external-user-1',
sourceType: ChatSourceTypeEnum.app,
source: 'share',
updateTime: now,
title: 'Chat 3'
......@@ -175,6 +179,7 @@ describe('getUsers API', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: teamMember2._id,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'Chat Search 1'
......@@ -185,6 +190,7 @@ describe('getUsers API', () => {
teamId: testTeamId,
tmbId: testTmbId,
outLinkUid: 'alice-user',
sourceType: ChatSourceTypeEnum.app,
source: 'share',
updateTime: now,
title: 'Chat Search 2'
......@@ -238,6 +244,7 @@ describe('getUsers API', () => {
teamId: testTeamId,
tmbId: testTmbId,
outLinkUid: 'user-a',
sourceType: ChatSourceTypeEnum.app,
source: 'share',
updateTime: now,
title: 'Sort 1'
......@@ -248,6 +255,7 @@ describe('getUsers API', () => {
teamId: testTeamId,
tmbId: testTmbId,
outLinkUid: 'user-a',
sourceType: ChatSourceTypeEnum.app,
source: 'share',
updateTime: now,
title: 'Sort 2'
......@@ -258,6 +266,7 @@ describe('getUsers API', () => {
teamId: testTeamId,
tmbId: testTmbId,
outLinkUid: 'user-a',
sourceType: ChatSourceTypeEnum.app,
source: 'share',
updateTime: now,
title: 'Sort 3'
......@@ -269,6 +278,7 @@ describe('getUsers API', () => {
teamId: testTeamId,
tmbId: testTmbId,
outLinkUid: 'user-b',
sourceType: ChatSourceTypeEnum.app,
source: 'share',
updateTime: now,
title: 'Sort 4'
......@@ -308,6 +318,7 @@ describe('getUsers API', () => {
teamId: testTeamId,
tmbId: testTmbId,
outLinkUid: 'online-user',
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'Online Chat'
......@@ -318,6 +329,7 @@ describe('getUsers API', () => {
teamId: testTeamId,
tmbId: testTmbId,
outLinkUid: 'share-user',
sourceType: ChatSourceTypeEnum.app,
source: 'share',
updateTime: now,
title: 'Share Chat'
......@@ -328,6 +340,7 @@ describe('getUsers API', () => {
teamId: testTeamId,
tmbId: testTmbId,
outLinkUid: 'api-user',
sourceType: ChatSourceTypeEnum.app,
source: 'api',
updateTime: now,
title: 'API Chat'
......
......@@ -13,7 +13,7 @@ import type {
getAppChatLogsBody,
getAppChatLogsResponseType
} from '@fastgpt/global/openapi/core/app/log/api';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { ChatRoleEnum, ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
type EmptyQuery = Record<string, never>;
......@@ -83,6 +83,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'Chat without error',
......@@ -94,6 +95,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'Chat with error',
......@@ -106,6 +108,7 @@ describe('logs list API - errorFilter', () => {
chatId: 'chat-all-1',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Normal response' } }],
......@@ -115,6 +118,7 @@ describe('logs list API - errorFilter', () => {
chatId: 'chat-all-2',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Error response' } }],
......@@ -163,6 +167,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'Chat without error',
......@@ -173,6 +178,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'Chat with error',
......@@ -183,6 +189,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'Another chat without error',
......@@ -196,6 +203,7 @@ describe('logs list API - errorFilter', () => {
chatId: 'chat-error-filter-1',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Normal response' } }],
......@@ -205,6 +213,7 @@ describe('logs list API - errorFilter', () => {
chatId: 'chat-error-filter-2',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Error response' } }],
......@@ -222,6 +231,7 @@ describe('logs list API - errorFilter', () => {
chatId: 'chat-error-filter-3',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Another normal response' } }],
......@@ -268,6 +278,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: new Date(now.getTime() - i * 1000),
title: `Chat with error ${i}`,
......@@ -281,6 +292,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: new Date(now.getTime() - (i + 5) * 1000),
title: `Chat without error ${i}`,
......@@ -295,6 +307,7 @@ describe('logs list API - errorFilter', () => {
chatId: chat.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Error response' } }],
......@@ -313,6 +326,7 @@ describe('logs list API - errorFilter', () => {
chatId: chat.chatId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Normal response' } }],
......@@ -376,6 +390,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'User 1 with error',
......@@ -387,6 +402,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'User 1 without error',
......@@ -398,6 +414,7 @@ describe('logs list API - errorFilter', () => {
appId: testAppId,
teamId: testTeamId,
tmbId: teamMember2._id,
sourceType: ChatSourceTypeEnum.app,
source: 'online',
updateTime: now,
title: 'User 2 with error',
......@@ -411,6 +428,7 @@ describe('logs list API - errorFilter', () => {
chatId: 'chat-user-error-1',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Error' } }],
......@@ -428,6 +446,7 @@ describe('logs list API - errorFilter', () => {
chatId: 'chat-user-error-2',
teamId: testTeamId,
tmbId: testTmbId,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Normal' } }],
......@@ -437,6 +456,7 @@ describe('logs list API - errorFilter', () => {
chatId: 'chat-user-error-3',
teamId: testTeamId,
tmbId: teamMember2._id,
sourceType: ChatSourceTypeEnum.app,
appId: testAppId,
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'Error' } }],
......
......@@ -4,7 +4,11 @@ import {
type AdminUpdateFeedbackResponseType
} from '@fastgpt/global/openapi/core/chat/feedback/api';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import {
ChatRoleEnum,
ChatSourceEnum,
ChatSourceTypeEnum
} from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
......@@ -13,6 +17,8 @@ import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest';
type EmptyQuery = Record<string, never>;
describe('adminUpdate api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string;
......@@ -38,6 +44,7 @@ describe('adminUpdate api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test
......@@ -48,6 +55,7 @@ describe('adminUpdate api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId,
......@@ -69,21 +77,22 @@ describe('adminUpdate api test', () => {
const q = 'What is AI?';
const a = 'AI stands for Artificial Intelligence';
const res = await Call<AdminUpdateFeedbackBodyType, {}, AdminUpdateFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
datasetId,
feedbackDataId,
q,
a
}
const res = await Call<
AdminUpdateFeedbackBodyType,
EmptyQuery,
AdminUpdateFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
datasetId,
feedbackDataId,
q,
a
}
);
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
......@@ -105,21 +114,22 @@ describe('adminUpdate api test', () => {
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser('unauthorized-user-admin');
const res = await Call<AdminUpdateFeedbackBodyType, {}, AdminUpdateFeedbackResponseType>(
handler,
{
auth: unauthorizedUser,
body: {
appId,
chatId,
dataId,
datasetId: getNanoid(),
feedbackDataId: getNanoid(),
q: 'test',
a: 'test'
}
const res = await Call<
AdminUpdateFeedbackBodyType,
EmptyQuery,
AdminUpdateFeedbackResponseType
>(handler, {
auth: unauthorizedUser,
body: {
appId,
chatId,
dataId,
datasetId: getNanoid(),
feedbackDataId: getNanoid(),
q: 'test',
a: 'test'
}
);
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
......
......@@ -4,7 +4,11 @@ import {
type CloseCustomFeedbackResponseType
} from '@fastgpt/global/openapi/core/chat/feedback/api';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import {
ChatRoleEnum,
ChatSourceEnum,
ChatSourceTypeEnum
} from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
......@@ -13,6 +17,8 @@ import { getUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { describe, expect, it, beforeEach } from 'vitest';
type EmptyQuery = Record<string, never>;
describe.sequential('closeCustom api test', () => {
let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string;
......@@ -38,6 +44,7 @@ describe.sequential('closeCustom api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test
......@@ -48,6 +55,7 @@ describe.sequential('closeCustom api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId,
......@@ -65,18 +73,19 @@ describe.sequential('closeCustom api test', () => {
});
it('should close custom feedback successfully', async () => {
const res = await Call<CloseCustomFeedbackBodyType, {}, CloseCustomFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
index: 1
}
const res = await Call<
CloseCustomFeedbackBodyType,
EmptyQuery,
CloseCustomFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
index: 1
}
);
});
expect(res.code).toBe(200);
expect(res.error).toBeUndefined();
......@@ -96,36 +105,38 @@ describe.sequential('closeCustom api test', () => {
it('should fail when user does not have permission', async () => {
const unauthorizedUser = await getUser(`unauthorized-user-close-${Math.random()}`);
const res = await Call<CloseCustomFeedbackBodyType, {}, CloseCustomFeedbackResponseType>(
handler,
{
auth: unauthorizedUser,
body: {
appId,
chatId,
dataId,
index: 0
}
const res = await Call<
CloseCustomFeedbackBodyType,
EmptyQuery,
CloseCustomFeedbackResponseType
>(handler, {
auth: unauthorizedUser,
body: {
appId,
chatId,
dataId,
index: 0
}
);
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('should handle closing first feedback', async () => {
const res = await Call<CloseCustomFeedbackBodyType, {}, CloseCustomFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
index: 0
}
const res = await Call<
CloseCustomFeedbackBodyType,
EmptyQuery,
CloseCustomFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
index: 0
}
);
});
expect(res.code).toBe(200);
......@@ -140,18 +151,19 @@ describe.sequential('closeCustom api test', () => {
});
it('should handle closing last feedback', async () => {
const res = await Call<CloseCustomFeedbackBodyType, {}, CloseCustomFeedbackResponseType>(
handler,
{
auth: testUser,
body: {
appId,
chatId,
dataId,
index: 2
}
const res = await Call<
CloseCustomFeedbackBodyType,
EmptyQuery,
CloseCustomFeedbackResponseType
>(handler, {
auth: testUser,
body: {
appId,
chatId,
dataId,
index: 2
}
);
});
expect(res.code).toBe(200);
......
......@@ -40,6 +40,7 @@ describe('getFeedbackRecordIds api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test
......@@ -51,6 +52,7 @@ describe('getFeedbackRecordIds api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'data-1',
......@@ -65,6 +67,7 @@ describe('getFeedbackRecordIds api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'data-2',
......@@ -79,6 +82,7 @@ describe('getFeedbackRecordIds api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'data-3',
......@@ -93,6 +97,7 @@ describe('getFeedbackRecordIds api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'data-4',
......@@ -107,6 +112,7 @@ describe('getFeedbackRecordIds api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'data-5',
......@@ -119,6 +125,7 @@ describe('getFeedbackRecordIds api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: 'data-6',
......
......@@ -4,7 +4,11 @@ import {
type UpdateFeedbackReadStatusResponseType
} from '@fastgpt/global/openapi/core/chat/feedback/api';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import {
ChatRoleEnum,
ChatSourceEnum,
ChatSourceTypeEnum
} from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
......@@ -39,6 +43,7 @@ describe('updateFeedbackReadStatus api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test
......@@ -49,6 +54,7 @@ describe('updateFeedbackReadStatus api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId,
......@@ -156,6 +162,7 @@ describe('updateFeedbackReadStatus api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: sharedDataId,
......@@ -175,6 +182,7 @@ describe('updateFeedbackReadStatus api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: sharedDataId,
......@@ -232,6 +240,7 @@ describe('updateFeedbackReadStatus api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: humanDataId,
......
......@@ -4,7 +4,11 @@ import {
type UpdateUserFeedbackResponseType
} from '@fastgpt/global/openapi/core/chat/feedback/api';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import {
ChatRoleEnum,
ChatSourceEnum,
ChatSourceTypeEnum
} from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
......@@ -39,6 +43,7 @@ describe('updateUserFeedback api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test
......@@ -63,6 +68,7 @@ describe('updateUserFeedback api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId,
......@@ -291,6 +297,7 @@ describe('updateUserFeedback api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId,
......
......@@ -63,6 +63,7 @@ describe('batchDelete api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test,
......@@ -78,6 +79,7 @@ describe('batchDelete api test', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: getNanoid(),
......@@ -100,6 +102,7 @@ describe('batchDelete api test', () => {
MongoChatItemResponse.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: getNanoid(),
......@@ -411,6 +414,7 @@ describe('batchDelete api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId: otherAppId,
chatId: otherChatId,
source: ChatSourceEnum.test,
......@@ -452,6 +456,7 @@ describe('batchDelete api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test,
......
import handler from '@/pages/api/core/chat/history/clearHistories';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSourceEnum, ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
......@@ -45,6 +45,7 @@ describe('clearHistories api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.online
......@@ -87,6 +88,7 @@ describe('clearHistories api test', () => {
await MongoChat.create({
teamId: otherUser.teamId,
tmbId: otherUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: otherChatId,
source: ChatSourceEnum.online
......@@ -133,6 +135,7 @@ describe('clearHistories api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: apiChatId,
source: ChatSourceEnum.api
......
......@@ -48,6 +48,7 @@ describe('delHistory api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test
......@@ -169,11 +170,20 @@ describe('delHistory api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId: legacyChatId,
source: ChatSourceEnum.test
})
]);
await MongoChat.updateOne(
{
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId: legacyChatId
},
{ $unset: { sourceType: '' } }
);
const res = await Call<any, { skillId: string; chatId: string }, any>(handler, {
auth: testUser,
......
......@@ -51,6 +51,7 @@ describe('getHistories api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: chatIds[0],
source: ChatSourceEnum.online,
......@@ -60,6 +61,7 @@ describe('getHistories api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: chatIds[1],
source: ChatSourceEnum.online,
......@@ -69,6 +71,7 @@ describe('getHistories api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: chatIds[2],
source: ChatSourceEnum.online,
......@@ -138,6 +141,7 @@ describe('getHistories api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: apiChatId,
source: ChatSourceEnum.api,
......@@ -243,6 +247,7 @@ describe('getHistories api test', () => {
await MongoChat.create({
teamId: otherUser.teamId,
tmbId: otherUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId: String(otherApp._id),
chatId: otherChatId,
source: ChatSourceEnum.online,
......@@ -292,6 +297,7 @@ describe('getHistories api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: shareChatId,
source: ChatSourceEnum.share,
......@@ -302,6 +308,7 @@ describe('getHistories api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: otherShareChatId,
source: ChatSourceEnum.share,
......@@ -482,12 +489,17 @@ describe('getHistories api test', () => {
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId: legacyChatId,
source: ChatSourceEnum.test,
title: 'Legacy Skill Debug Session'
})
]);
await MongoChat.updateOne(
{ appId: skillId, chatId: legacyChatId },
{ $unset: { sourceType: '' } }
);
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
auth: testUser,
......
import handler from '@/pages/api/core/chat/history/updateHistory';
import type { UpdateHistoryBodyType } from '@fastgpt/global/openapi/core/chat/history/api';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSourceEnum, ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
......@@ -45,6 +45,7 @@ describe('updateHistory api test', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test,
......
......@@ -31,6 +31,7 @@ describe('delete chat record api', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId,
......@@ -60,6 +61,7 @@ describe('delete chat record api', () => {
await MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.test
......@@ -180,7 +182,7 @@ describe('delete chat record api', () => {
const skillChatId = getNanoid();
const contentId = getNanoid();
await Promise.all([
const [, , legacyItem] = await Promise.all([
MongoChat.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
......@@ -204,6 +206,7 @@ describe('delete chat record api', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId: skillChatId,
dataId: contentId,
......@@ -211,6 +214,17 @@ describe('delete chat record api', () => {
value: [{ type: 'text', text: { content: 'legacy answer' } }]
})
]);
await MongoChatItem.updateOne({ _id: legacyItem._id }, { $unset: { sourceType: '' } });
await MongoChatItem.updateOne(
{
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId: skillChatId,
dataId: contentId,
'value.0.text.content': 'legacy answer'
},
{ $unset: { sourceType: '' } }
);
const res = await Call<DeleteChatRecordBodyType, Record<string, never>>(handler, {
auth: testUser,
......@@ -223,7 +237,7 @@ describe('delete chat record api', () => {
expect(res.code).toBe(200);
const [skillItem, legacyItem] = await Promise.all([
const [skillItem, legacyResultItem] = await Promise.all([
MongoChatItem.findOne({
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
......@@ -238,7 +252,7 @@ describe('delete chat record api', () => {
}).lean()
]);
expect(skillItem?.deleteTime).toBeInstanceOf(Date);
expect(legacyItem?.deleteTime).toBeNull();
expect(legacyResultItem?.deleteTime).toBeNull();
});
it('should reject read-only skill collaborator when deleting skill edit chat item', async () => {
......
......@@ -92,6 +92,7 @@ describe('getRecords_v2 skill edit target', () => {
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId,
dataId: getNanoid(),
......@@ -99,6 +100,15 @@ describe('getRecords_v2 skill edit target', () => {
value: [{ type: 'text', text: { content: 'legacy item' } }]
})
]);
await MongoChatItem.updateOne(
{
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId,
'value.0.text.content': 'legacy item'
},
{ $unset: { sourceType: '' } }
);
const res = await Call<GetRecordsV2BodyType, any, GetRecordsV2ResponseType>(handler, {
auth: testUser,
......
......@@ -76,6 +76,7 @@ describe('system openapi chat auth', () => {
await MongoChat.create({
teamId: user.teamId,
tmbId: user.tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
source: ChatSourceEnum.api,
......@@ -89,6 +90,7 @@ describe('system openapi chat auth', () => {
teamId: user.teamId,
tmbId: user.tmbId,
userId: user.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: firstDataId,
......@@ -105,6 +107,7 @@ describe('system openapi chat auth', () => {
teamId: user.teamId,
tmbId: user.tmbId,
userId: user.userId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId,
dataId: secondDataId,
......
import { afterEach, describe, expect, it, vi } from 'vitest';
import { isWorkflowShortcutInputtingTarget } from '@/pageComponents/app/detail/WorkflowComponents/Flow/hooks/keyboard';
const createElementMock = ({
closestMap = {},
className = ''
}: {
closestMap?: Record<string, unknown>;
className?: string;
}) => {
const element = {
nodeType: 1,
className,
closest: vi.fn((selector: string) => closestMap[selector] ?? null),
getAttribute: vi.fn((attr: string) => {
if (attr !== 'contenteditable') return null;
return 'true';
})
};
return element;
};
describe('isWorkflowShortcutInputtingTarget', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('should treat native form input targets as inputting', () => {
const input = createElementMock({
closestMap: {
'input, textarea, select': true
}
});
expect(isWorkflowShortcutInputtingTarget(input as unknown as EventTarget)).toBe(true);
});
it('should treat Lexical contenteditable selection as inputting', () => {
const editor = createElementMock({
closestMap: {
'[contenteditable]': {
getAttribute: () => 'true'
}
}
});
vi.stubGlobal('window', {
getSelection: () => ({
rangeCount: 1,
isCollapsed: false,
anchorNode: {
parentElement: editor
},
focusNode: {
parentElement: editor
}
})
});
expect(isWorkflowShortcutInputtingTarget(undefined)).toBe(true);
});
it('should ignore collapsed selections outside editable targets', () => {
vi.stubGlobal('window', {
getSelection: () => ({
rangeCount: 1,
isCollapsed: true,
anchorNode: null,
focusNode: null
})
});
expect(isWorkflowShortcutInputtingTarget(undefined)).toBe(false);
});
it('should not treat canvas targets as inputting', () => {
const canvas = createElementMock({});
expect(isWorkflowShortcutInputtingTarget(canvas as unknown as EventTarget)).toBe(false);
});
});
......@@ -77,6 +77,7 @@ const createLegacySkillDebugChat = async ({
await MongoChat.create({
teamId,
tmbId,
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId,
source: ChatSourceEnum.test,
......@@ -85,6 +86,7 @@ const createLegacySkillDebugChat = async ({
await MongoChatItem.create({
teamId,
tmbId,
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId,
dataId: `${chatId}-item`,
......@@ -93,6 +95,7 @@ const createLegacySkillDebugChat = async ({
});
await MongoChatItemResponse.create({
teamId,
sourceType: ChatSourceTypeEnum.skillEdit,
appId: skillId,
chatId,
chatItemDataId: `${chatId}-item`,
......
......@@ -135,6 +135,7 @@ describe('cleanupDuplicateChats data clean API', () => {
await MongoChatItem.create({
teamId,
tmbId,
sourceType: ChatSourceTypeEnum.app,
appId,
chatId: 'duplicate-chat',
dataId: 'item-1',
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { ChatSourceEnum, ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat';
import { AppErrEnum } from '@fastgpt/global/common/error/code/app';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
......@@ -311,6 +311,7 @@ describe('authChatCompletionHeaderRequest', () => {
chatId,
teamId: owner.teamId,
tmbId: member.tmbId,
sourceType: ChatSourceTypeEnum.app,
source: ChatSourceEnum.api
});
......@@ -346,6 +347,7 @@ describe('authChatCompletionHeaderRequest', () => {
chatId,
teamId: owner.teamId,
tmbId: memberA.tmbId,
sourceType: ChatSourceTypeEnum.app,
source: ChatSourceEnum.api
});
......
......@@ -35,9 +35,7 @@ export default defineConfig({
process.env.FILE_TOKEN_KEY ??
'bfd697e7e798f75deaf2d31210bc93a2e41ad4eed9e7831071d77821b7b97cff',
AES256_SECRET_KEY: process.env.AES256_SECRET_KEY ?? 'fastgpt_test_aes256_secret_key',
INVOKE_TOKEN_SECRET:
process.env.INVOKE_TOKEN_SECRET ?? 'fastgpt_test_invoke_token_secret_32',
AGENT_SANDBOX_PROVIDER: 'opensandbox'
INVOKE_TOKEN_SECRET: process.env.INVOKE_TOKEN_SECRET ?? 'fastgpt_test_invoke_token_secret_32'
},
coverage: {
enabled: true,
......
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