Commit 725529dd by Archer Committed by GitHub

perf: reduce workflow variable replacement CPU cost (#7108)

* perf: reduce workflow variable replacement CPU cost

* doc

* doc

* refactor: move workflow variable replacement to service

* test: cover variable key detection

* refactor: move workflow node params helper

* refactor: remove default llm request params

* fix: stop oversized variable replacement rounds

* fix: stop oversized variable replacement without throwing

* doc

* fix: i18n

* fix: error show

* chore: log oversized string replacement
parent 53d39a8f
......@@ -4,6 +4,7 @@ stringData:
OPENAI_BASE_URL: "https://api.openai.com/v1"
CHAT_API_KEY: "sk-xxxx"
DB_MAX_LINK: "5"
SYSTEM_MAX_STRING_LENGTH_M: "{{ .Values.system.maxStringLengthM }}"
TOKEN_KEY: "any"
ROOT_KEY: "root_key"
FILE_TOKEN_KEY: "filetoken"
......
......@@ -92,6 +92,11 @@ autoscaling:
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
system:
# Maximum character length for synchronous system string operations, in M characters.
# 1 means 1,000,000 characters. Valid range: 1 to 100.
maxStringLengthM: 100
# Additional volumes on the output Deployment definition.
volumes: []
# - name: foo
......
......@@ -173,6 +173,8 @@ ${{vec.db}}
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -176,27 +176,28 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
### Feature Flags and Limits
| Variable | Default | Description |
| -------------------------------------- | --------- | ---------------------------------------------------------------------------------------------- |
| `AGENT_ENGINE` | `default` | Agent engine. Supported values are `default` and `pi`. |
| `HELPER_BOT_MODEL` | Empty | Helper generation model. The model must be enabled in the system. |
| `SKIP_FILE_TYPE_CHECK` | `false` | Whether upload file type checks are skipped. |
| `WECHAT_CHANNEL_CONCURRENCY` | `1000` | WeChat channel poll worker concurrency. Minimum value is `10`. |
| `PARSE_FILE_WORKERS` | `10` | Resident file parsing worker count. |
| `HTML_TO_MARKDOWN_WORKERS` | `10` | Resident HTML-to-Markdown worker count. |
| `TEXT_TO_CHUNKS_WORKERS` | `10` | Resident text chunking worker count. |
| `PARSE_FILE_TIMEOUT_SECONDS` | `600` | Timeout for one file parsing task, in seconds. |
| `WORKFLOW_MAX_RUN_TIMES` | `500` | Maximum workflow run count to avoid extreme infinite loops. |
| `WORKFLOW_MAX_LOOP_TIMES` | `100` | Maximum input array length for loop and parallel nodes. |
| `WORKFLOW_PARALLEL_MAX_CONCURRENCY` | `10` | Parallel node concurrency limit. It must not exceed `WORKFLOW_MAX_LOOP_TIMES`. |
| `CHAT_MAX_QPM` | `5000` | Chat QPM limit. User plan limits take precedence when configured. |
| `SERVICE_REQUEST_MAX_CONTENT_LENGTH` | `10` | Maximum request body size accepted by the service, in MB. |
| `APP_FOLDER_MAX_AMOUNT` | `1000` | Maximum number of App folders. |
| `DATASET_FOLDER_MAX_AMOUNT` | `1000` | Maximum number of dataset folders. |
| `UPLOAD_FILE_MAX_SIZE` | `1000` | Maximum upload file size, in MB. |
| `UPLOAD_FILE_MAX_AMOUNT` | `1000` | Maximum upload file count. |
| `LLM_REQUEST_TRACKING_RETENTION_HOURS` | `6` | LLM request tracking retention, in hours. |
| `MAX_HTML_TRANSFORM_CHARS` | `1000000` | Maximum number of characters for HTML-to-Markdown conversion. Larger content is not converted. |
| Variable | Default | Description |
| -------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AGENT_ENGINE` | `default` | Agent engine. Supported values are `default` and `pi`. |
| `HELPER_BOT_MODEL` | Empty | Helper generation model. The model must be enabled in the system. |
| `SKIP_FILE_TYPE_CHECK` | `false` | Whether upload file type checks are skipped. |
| `WECHAT_CHANNEL_CONCURRENCY` | `1000` | WeChat channel poll worker concurrency. Minimum value is `10`. |
| `PARSE_FILE_WORKERS` | `10` | Resident file parsing worker count. |
| `HTML_TO_MARKDOWN_WORKERS` | `10` | Resident HTML-to-Markdown worker count. |
| `TEXT_TO_CHUNKS_WORKERS` | `10` | Resident text chunking worker count. |
| `PARSE_FILE_TIMEOUT_SECONDS` | `600` | Timeout for one file parsing task, in seconds. |
| `WORKFLOW_MAX_RUN_TIMES` | `500` | Maximum workflow run count to avoid extreme infinite loops. |
| `WORKFLOW_MAX_LOOP_TIMES` | `100` | Maximum input array length for loop and parallel nodes. |
| `WORKFLOW_PARALLEL_MAX_CONCURRENCY` | `10` | Parallel node concurrency limit. It must not exceed `WORKFLOW_MAX_LOOP_TIMES`. |
| `SYSTEM_MAX_STRING_LENGTH_M` | `100` | Maximum character length for synchronous system string operations such as variable replacement, in M characters. `1` means `1,000,000` characters. Valid range: `1` to `100`. |
| `CHAT_MAX_QPM` | `5000` | Chat QPM limit. User plan limits take precedence when configured. |
| `SERVICE_REQUEST_MAX_CONTENT_LENGTH` | `10` | Maximum request body size accepted by the service, in MB. |
| `APP_FOLDER_MAX_AMOUNT` | `1000` | Maximum number of App folders. |
| `DATASET_FOLDER_MAX_AMOUNT` | `1000` | Maximum number of dataset folders. |
| `UPLOAD_FILE_MAX_SIZE` | `1000` | Maximum upload file size, in MB. |
| `UPLOAD_FILE_MAX_AMOUNT` | `1000` | Maximum upload file count. |
| `LLM_REQUEST_TRACKING_RETENTION_HOURS` | `6` | LLM request tracking retention, in hours. |
| `MAX_HTML_TRANSFORM_CHARS` | `1000000` | Maximum number of characters for HTML-to-Markdown conversion. Larger content is not converted. |
## App-Specific Variables
......
......@@ -176,27 +176,28 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
### 功能开关与限制
| 变量 | 默认值 | 说明 |
| -------------------------------------- | --------- | -------------------------------------------------------- |
| `AGENT_ENGINE` | `default` | Agent 引擎,可选 `default` 或 `pi`。 |
| `HELPER_BOT_MODEL` | 空 | 辅助生成模型,需保证系统中已启用对应模型。 |
| `SKIP_FILE_TYPE_CHECK` | `false` | 是否跳过上传文件类型检查。 |
| `WECHAT_CHANNEL_CONCURRENCY` | `1000` | 微信渠道 poll worker 并发数,最小 `10`。 |
| `PARSE_FILE_WORKERS` | `10` | 文件解析 worker 常驻线程数。 |
| `HTML_TO_MARKDOWN_WORKERS` | `10` | HTML 转 Markdown worker 常驻线程数。 |
| `TEXT_TO_CHUNKS_WORKERS` | `10` | 文本切块 worker 常驻线程数。 |
| `PARSE_FILE_TIMEOUT_SECONDS` | `600` | 文件解析单任务超时时间,单位秒。 |
| `WORKFLOW_MAX_RUN_TIMES` | `500` | 工作流最大运行次数,避免极端死循环。 |
| `WORKFLOW_MAX_LOOP_TIMES` | `100` | 循环/并行节点最大输入数组长度。 |
| `WORKFLOW_PARALLEL_MAX_CONCURRENCY` | `10` | 并行节点并发上限,且不能超过 `WORKFLOW_MAX_LOOP_TIMES`。 |
| `CHAT_MAX_QPM` | `5000` | 聊天 QPM 限制;若用户套餐另有限制,以套餐限制为准。 |
| `SERVICE_REQUEST_MAX_CONTENT_LENGTH` | `10` | 服务端接收请求体最大大小,单位 MB。 |
| `APP_FOLDER_MAX_AMOUNT` | `1000` | 应用文件夹最大数量。 |
| `DATASET_FOLDER_MAX_AMOUNT` | `1000` | 数据集文件夹最大数量。 |
| `UPLOAD_FILE_MAX_SIZE` | `1000` | 最大上传文件大小,单位 MB。 |
| `UPLOAD_FILE_MAX_AMOUNT` | `1000` | 最大上传文件数量。 |
| `LLM_REQUEST_TRACKING_RETENTION_HOURS` | `6` | LLM 请求追踪保留时长,单位小时。 |
| `MAX_HTML_TRANSFORM_CHARS` | `1000000` | HTML 转 Markdown 的最大字符数,超过后不转换。 |
| 变量 | 默认值 | 说明 |
| -------------------------------------- | --------- | ---------------------------------------------------------------------------------------------- |
| `AGENT_ENGINE` | `default` | Agent 引擎,可选 `default` 或 `pi`。 |
| `HELPER_BOT_MODEL` | 空 | 辅助生成模型,需保证系统中已启用对应模型。 |
| `SKIP_FILE_TYPE_CHECK` | `false` | 是否跳过上传文件类型检查。 |
| `WECHAT_CHANNEL_CONCURRENCY` | `1000` | 微信渠道 poll worker 并发数,最小 `10`。 |
| `PARSE_FILE_WORKERS` | `10` | 文件解析 worker 常驻线程数。 |
| `HTML_TO_MARKDOWN_WORKERS` | `10` | HTML 转 Markdown worker 常驻线程数。 |
| `TEXT_TO_CHUNKS_WORKERS` | `10` | 文本切块 worker 常驻线程数。 |
| `PARSE_FILE_TIMEOUT_SECONDS` | `600` | 文件解析单任务超时时间,单位秒。 |
| `WORKFLOW_MAX_RUN_TIMES` | `500` | 工作流最大运行次数,避免极端死循环。 |
| `WORKFLOW_MAX_LOOP_TIMES` | `100` | 循环/并行节点最大输入数组长度。 |
| `WORKFLOW_PARALLEL_MAX_CONCURRENCY` | `10` | 并行节点并发上限,且不能超过 `WORKFLOW_MAX_LOOP_TIMES`。 |
| `SYSTEM_MAX_STRING_LENGTH_M` | `100` | 系统变量替换等同步字符串处理最大字符数,单位 M;`1` 表示 `1,000,000` 字符,范围 `1` 到 `100`。 |
| `CHAT_MAX_QPM` | `5000` | 聊天 QPM 限制;若用户套餐另有限制,以套餐限制为准。 |
| `SERVICE_REQUEST_MAX_CONTENT_LENGTH` | `10` | 服务端接收请求体最大大小,单位 MB。 |
| `APP_FOLDER_MAX_AMOUNT` | `1000` | 应用文件夹最大数量。 |
| `DATASET_FOLDER_MAX_AMOUNT` | `1000` | 数据集文件夹最大数量。 |
| `UPLOAD_FILE_MAX_SIZE` | `1000` | 最大上传文件大小,单位 MB。 |
| `UPLOAD_FILE_MAX_AMOUNT` | `1000` | 最大上传文件数量。 |
| `LLM_REQUEST_TRACKING_RETENTION_HOURS` | `6` | LLM 请求追踪保留时长,单位小时。 |
| `MAX_HTML_TRANSFORM_CHARS` | `1000000` | HTML 转 Markdown 的最大字符数,超过后不转换。 |
## App 额外变量
......
......@@ -11,7 +11,12 @@ description: 'FastGPT V4.15.0-beta5 更新说明'
1. HTML 输出后自动切换为预览,减少手动打开预览的操作。
2. 优化应用、知识库、文件和文件夹等长名称展示:超出宽度时自动省略,并在 hover 名称时展示完整内容。
3. 移除所有内置 LLM 请求中的 `temperature` 和 `max_tokens`,避免部分模型不兼容。
## 🐛 修复
1. 修复 S3 私有对象 key 未绑定已鉴权资源时可能导致的跨资源文件访问风险。
## 代码优化
1. 增加系统处理字符串时的长度保护,如果长度过大会停止继续同步替换,避免高 CPU 负载,可通过环境变量 `SYSTEM_MAX_STRING_LENGTH_M` 调整上限。
......@@ -130,11 +130,39 @@
"content/guide/workspace/team/team_roles_permissions.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/workspace/team/team_roles_permissions.mdx": "2026-05-07T15:06:40+08:00",
"content/openapi/app.en.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/app.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/chat.en.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/chat.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/dataset.en.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/dataset.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/index.en.mdx": "2026-04-26T21:08:47+08:00",
"content/openapi/index.mdx": "2026-04-26T21:08:47+08:00",
"content/openapi/intro.en.mdx": "2026-05-29T21:03:14+08:00",
"content/openapi/intro.mdx": "2026-05-29T21:03:14+08:00",
"content/openapi/share.en.mdx": "2026-04-26T21:08:47+08:00",
"content/openapi/share.mdx": "2026-04-26T21:08:47+08:00",
"content/plugin/index.en.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/index.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/intro.en.mdx": "2026-06-09T16:03:58+08:00",
"content/plugin/intro.mdx": "2026-06-09T16:03:58+08:00",
"content/plugin/model-presets.en.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/system-tool-development.en.mdx": "2026-06-09T16:03:58+08:00",
"content/plugin/system-tool-development.mdx": "2026-06-09T16:03:58+08:00",
"content/self-host/config/env.en.mdx": "2026-06-13T15:48:04+08:00",
"content/self-host/config/env.mdx": "2026-06-13T15:48:04+08:00",
"content/self-host/config/json.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/config/json.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/intro.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/minimax.en.mdx": "2026-06-03T10:40:17+08:00",
"content/self-host/config/model/minimax.mdx": "2026-06-03T10:40:17+08:00",
"content/self-host/config/model/siliconCloud.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/config/model/siliconCloud.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/config/object-storage.en.mdx": "2026-05-21T11:24:48+08:00",
"content/self-host/config/object-storage.mdx": "2026-05-21T11:24:48+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",
"content/self-host/custom-models/bge-rerank.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/custom-models/chatglm2-m3e.en.mdx": "2026-04-26T21:08:47+08:00",
......@@ -248,8 +276,8 @@
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41504.en.mdx": "2026-06-10T19:02:59+08:00",
"content/self-host/upgrading/4-15/41504.mdx": "2026-06-11T17:42:15+08:00",
"content/self-host/upgrading/4-15/41505.en.mdx": "2026-06-12T00:30:58+08:00",
"content/self-host/upgrading/4-15/41505.mdx": "2026-06-12T00:30:58+08:00",
"content/self-host/upgrading/4-15/41505.en.mdx": "2026-06-12T20:47:04+08:00",
"content/self-host/upgrading/4-15/41505.mdx": "2026-06-13T22:52:27+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",
......
......@@ -230,6 +230,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -208,6 +208,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -192,6 +192,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -190,6 +190,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -195,6 +195,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -174,6 +174,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -230,6 +230,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -208,6 +208,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -192,6 +192,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -190,6 +190,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -195,6 +195,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
......@@ -174,6 +174,8 @@ services:
DB_MAX_LINK: 5
# 自动同步索引
SYNC_INDEX: true
# 系统变量替换等同步字符串处理的最大字符数,单位 M,范围 1~100
SYSTEM_MAX_STRING_LENGTH_M: 100
TOKEN_KEY: fastgpt
# 文件阅读时的密钥
FILE_TOKEN_KEY: filetokenkey
......
import crypto from 'crypto';
import { customAlphabet } from 'nanoid';
import path from 'path';
import { getErrText } from '../error/utils';
export const checkStrOversize = (str: string, size = 1e8) => {
if (str.length > size) {
return true;
}
return false;
};
/* check string is a web link */
export function strIsLink(str?: string) {
......@@ -34,103 +26,6 @@ export const simpleText = (text = '') => {
return text;
};
export const valToStr = (val: any) => {
if (val === undefined) return '';
if (val === null) return 'null';
if (typeof val === 'object') {
try {
const start = Date.now();
const res = JSON.stringify(val);
if (Date.now() - start > 1000) {
console.warn('Slow JSON.stringify', {
duration: Date.now() - start,
valLength: res.length
});
}
return res;
} catch (error) {
console.error('Failed to stringify value', { error });
return `Failed to stringify value: ${getErrText(error)}`;
}
}
return String(val);
};
// replace {{variable}} to value
export function replaceVariable(text: any, obj: Record<string, any>, depth = 0) {
if (typeof text !== 'string') return text;
if (checkStrOversize(text)) {
throw new Error('Text length exceeds 100,000,000 characters.');
}
const MAX_REPLACEMENT_DEPTH = 10;
const processedVariables = new Set<string>();
// Prevent infinite recursion
if (depth > MAX_REPLACEMENT_DEPTH) {
return text;
}
// Check for circular references in variable values
const hasCircularReference = (value: any, targetKey: string): boolean => {
if (typeof value !== 'string') return false;
// Check if the value contains the target variable pattern (direct self-reference)
const selfRefPattern = new RegExp(
`\\{\\{${targetKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`,
'g'
);
return selfRefPattern.test(value);
};
let result = text;
let hasReplacements = false;
// Build replacement map first to avoid modifying string during iteration
const replacements: { pattern: string; replacement: string }[] = [];
for (const key in obj) {
// Skip if already processed to avoid immediate circular reference
if (processedVariables.has(key)) {
continue;
}
const val = obj[key];
// Check for direct circular reference
if (hasCircularReference(String(val), key)) {
continue;
}
const formatVal = valToStr(val);
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
replacements.push({
pattern: `{{${escapedKey}}}`,
replacement: formatVal
});
processedVariables.add(key);
hasReplacements = true;
}
// Apply all replacements
replacements.forEach(({ pattern, replacement }) => {
result = result.replace(new RegExp(pattern, 'g'), () => replacement);
});
// If we made replacements and there might be nested variables, recursively process
if (hasReplacements && /\{\{[^}]+\}\}/.test(result)) {
result = replaceVariable(result, obj, depth + 1);
}
return result || '';
}
/* replace sensitive text */
export const replaceSensitiveText = (text: string) => {
// 1. http link
......
......@@ -60,6 +60,20 @@ export enum AppLogTimespanEnum {
month = 'month',
quarter = 'quarter'
}
export const AppLogTimespanMap: Record<AppLogTimespanEnum, { label: string }> = {
[AppLogTimespanEnum.day]: {
label: i18nT('app:logs_timespan_day')
},
[AppLogTimespanEnum.week]: {
label: i18nT('app:logs_timespan_week')
},
[AppLogTimespanEnum.month]: {
label: i18nT('app:logs_timespan_month')
},
[AppLogTimespanEnum.quarter]: {
label: i18nT('app:logs_timespan_quarter')
}
};
export const offsetOptions = [
{ label: 'T+1', value: '1' },
......
import json5 from 'json5';
import { checkStrOversize, replaceVariable, valToStr } from '../../../common/string/tools';
import { ChatRoleEnum } from '../../../core/chat/constants';
import type { ChatItemMiniType } from '../../../core/chat/type';
import type { NodeOutputItemType } from './type';
......@@ -361,146 +360,6 @@ export const formatVariableValByType = (val: any, valueType?: WorkflowIOValueTyp
return val;
};
// 模块级 RegExp 缓存,避免每次变量替换都重新编译正则
const _replaceRegexCache = new Map<string, RegExp>();
const _MAX_REGEX_CACHE_SIZE = 5000;
const _getCachedRegex = (pattern: string): RegExp => {
let re = _replaceRegexCache.get(pattern);
if (!re) {
if (_replaceRegexCache.size >= _MAX_REGEX_CACHE_SIZE) {
_replaceRegexCache.clear();
}
re = new RegExp(pattern, 'g');
_replaceRegexCache.set(pattern, re);
}
return re;
};
// replace {{$xx.xx$}} variables for text
export function replaceEditorVariable({
text,
nodesMap,
variables,
depth = 0
}: {
text: any;
nodesMap: Record<string, RuntimeNodeItemType> | Map<string, RuntimeNodeItemType>;
variables: Record<string, unknown>; // runtime global variables
depth?: number;
}) {
const getNode = (nodeId: string) => {
return nodesMap instanceof Map ? nodesMap.get(nodeId) : nodesMap[nodeId];
};
if (typeof text !== 'string') return text;
if (text === '') return text;
if (checkStrOversize(text)) {
throw new Error('Text length exceeds 100,000,000 characters.');
}
const MAX_REPLACEMENT_DEPTH = 10;
const processedVariables = new Set<string>();
// Prevent infinite recursion
if (depth > MAX_REPLACEMENT_DEPTH) {
return text;
}
text = replaceVariable(text, variables);
// Check for circular references in variable values
const hasCircularReference = (value: any, targetKey: string): boolean => {
if (typeof value !== 'string') return false;
// Check if the value contains the target variable pattern (direct self-reference)
const selfRefPattern = _getCachedRegex(
`\\{\\{\\$${targetKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\$\\}\\}`
);
selfRefPattern.lastIndex = 0;
return selfRefPattern.test(value);
};
const variablePattern = /\{\{\$([^.]+)\.([^$]+)\$\}\}/g;
const matches = [...text.matchAll(variablePattern)];
if (matches.length === 0) return text;
let result = text;
let hasReplacements = false;
// Build replacement map first to avoid modifying string during iteration
const replacements: Array<{ pattern: string; replacement: string }> = [];
const variableRegex = /[.*+?^${}()|[\]\\]/g;
for (const match of matches) {
const nodeId = match[1];
const id = match[2];
const variableKey = `${nodeId}.${id}`;
// Skip if already processed to avoid immediate circular reference
if (processedVariables.has(variableKey)) {
continue;
}
const variableVal = (() => {
if (nodeId === VARIABLE_NODE_ID) {
return variables[id];
}
// Find upstream node input/output
const node = getNode(nodeId);
if (!node) return;
const output = node.outputs.find((output) => output.id === id);
if (output) return formatVariableValByType(output.value, output.valueType);
// Use the node's input as the variable value(Example: HTTP data will reference its own dynamic input)
const input = node.inputs.find((input) => input.key === id);
if (input) {
return getReferenceVariableValue({
value: input.value,
nodesMap,
variables
});
}
})();
// Check for direct circular reference
if (hasCircularReference(String(variableVal), variableKey)) {
continue;
}
const formatVal = valToStr(variableVal);
const escapedNodeId = nodeId.replace(variableRegex, '\\$&');
const escapedId = id.replace(variableRegex, '\\$&');
replacements.push({
pattern: `\\{\\{\\$${escapedNodeId}\\.${escapedId}\\$\\}\\}`,
replacement: formatVal
});
processedVariables.add(variableKey);
hasReplacements = true;
}
// Apply all replacements
for (const { pattern, replacement } of replacements) {
if (checkStrOversize(result)) {
console.warn('Text length exceeds 100,000,000 characters.');
break;
}
const re = _getCachedRegex(pattern);
re.lastIndex = 0;
result = result.replace(re, () => replacement);
}
// If we made replacements and there might be nested variables, recursively process
if (hasReplacements && /\{\{\$[^.]+\.[^$]+\$\}\}/.test(result)) {
result = replaceEditorVariable({ text: result, nodesMap, variables, depth: depth + 1 });
}
return result || '';
}
export const textAdaptGptResponse = ({
text,
reasoning_content,
......
......@@ -6,12 +6,10 @@ import {
hashStr,
replaceRegChars,
replaceSensitiveText,
replaceVariable,
simpleText,
sliceJsonStr,
sliceStrStartEnd,
strIsLink,
valToStr
strIsLink
} from '@fastgpt/global/common/string/tools';
describe('string tools', () => {
......@@ -37,44 +35,6 @@ describe('string tools', () => {
expect(simpleText('a b')).toBe('a b');
});
it('should convert values to strings', () => {
expect(valToStr(undefined)).toBe('');
expect(valToStr(null)).toBe('null');
expect(valToStr({ a: 1 })).toBe('{"a":1}');
expect(valToStr(123)).toBe('123');
});
it('should replace variables with recursion and safeguards', () => {
expect(replaceVariable('Hello {{name}}', { name: 'Ada' })).toBe('Hello Ada');
expect(
replaceVariable('Hello {{name}}', {
name: '{{first}} {{last}}',
first: 'Ada',
last: 'Lovelace'
})
).toBe('Hello Ada Lovelace');
expect(replaceVariable('Hello {{name}}', { name: undefined })).toBe('Hello ');
expect(replaceVariable('Hello {{name}}', { name: '{{name}}' })).toBe('Hello {{name}}');
expect(replaceVariable(123 as any, { name: 'Ada' })).toBe(123);
});
it('should treat $ special characters in replacement value as literals', () => {
// $1, $2 不应被解释为捕获组引用
expect(replaceVariable('value: {{val}}', { val: '$1' })).toBe('value: $1');
expect(replaceVariable('value: {{val}}', { val: '$2' })).toBe('value: $2');
// $$ 不应被解释为字面量 $
expect(replaceVariable('value: {{val}}', { val: '$$' })).toBe('value: $$');
// $& 不应被替换为整个匹配字符串
expect(replaceVariable('value: {{val}}', { val: '$&' })).toBe('value: $&');
// $` 和 $' 不应被替换为匹配前/后的内容
expect(replaceVariable('value: {{val}}', { val: "$'" })).toBe("value: $'");
expect(replaceVariable('value: {{val}}', { val: '$`' })).toBe('value: $`');
// 混合场景
expect(replaceVariable('result={{a}}&other={{b}}', { a: '$1', b: '$2' })).toBe(
'result=$1&other=$2'
);
});
it('should replace sensitive text', () => {
expect(replaceSensitiveText('Visit https://example.com/path?x=1')).toBe('Visit https://xxx');
expect(replaceSensitiveText('token ns-abc-123 and ns-xyz')).toBe('token xxx and xxx');
......
......@@ -10,7 +10,6 @@ import {
filterWorkflowEdges,
getReferenceVariableValue as baseGetReferenceVariableValue,
formatVariableValByType,
replaceEditorVariable,
textAdaptGptResponse,
rewriteNodeOutputByHistories
} from '@fastgpt/global/core/workflow/runtime/utils';
......@@ -21,8 +20,8 @@ import {
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import {
FlowNodeTypeEnum,
FlowNodeOutputTypeEnum
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
......@@ -1500,273 +1499,6 @@ describe('formatVariableValByType', () => {
});
});
describe('replaceEditorVariable', () => {
it('should return non-string values as is', () => {
expect(replaceEditorVariable({ text: 123, nodesMap: {}, variables: {} })).toBe(123);
expect(replaceEditorVariable({ text: null, nodesMap: {}, variables: {} })).toBe(null);
});
it('should return empty string as is', () => {
expect(replaceEditorVariable({ text: '', nodesMap: {}, variables: {} })).toBe('');
});
it('should replace global variables', () => {
const result = replaceEditorVariable({
text: 'Hello {{name}}',
nodesMap: {},
variables: { name: 'World' }
});
expect(result).toBe('Hello World');
});
it('should replace node output variables', () => {
const nodesMap: Record<string, RuntimeNodeItemType> = {
node1: {
nodeId: 'node1',
name: 'test',
flowNodeType: FlowNodeTypeEnum.chatNode,
inputs: [],
outputs: [
{
id: 'out1',
key: 'output1',
type: FlowNodeOutputTypeEnum.static,
value: 'outputValue',
valueType: WorkflowIOValueTypeEnum.string
}
]
}
};
const result = replaceEditorVariable({
text: 'Result: {{$node1.out1$}}',
nodesMap,
variables: {}
});
expect(result).toBe('Result: outputValue');
});
it('should replace VARIABLE_NODE_ID variables', () => {
const result = replaceEditorVariable({
text: `Value: {{$${VARIABLE_NODE_ID}.myVar$}}`,
nodesMap: {},
variables: { myVar: 'varValue' }
});
expect(result).toBe('Value: varValue');
});
it('should handle nested variable replacement', () => {
const nodesMap: Record<string, RuntimeNodeItemType> = {
node1: {
nodeId: 'node1',
name: 'test',
flowNodeType: FlowNodeTypeEnum.chatNode,
inputs: [],
outputs: [
{
id: 'out1',
key: 'output1',
type: FlowNodeOutputTypeEnum.static,
value: '{{$node2.out2$}}',
valueType: WorkflowIOValueTypeEnum.string
}
]
},
node2: {
nodeId: 'node2',
name: 'test2',
flowNodeType: FlowNodeTypeEnum.chatNode,
inputs: [],
outputs: [
{
id: 'out2',
key: 'output2',
type: FlowNodeOutputTypeEnum.static,
value: 'finalValue',
valueType: WorkflowIOValueTypeEnum.string
}
]
}
};
const result = replaceEditorVariable({
text: 'Result: {{$node1.out1$}}',
nodesMap,
variables: {}
});
expect(result).toBe('Result: finalValue');
});
it('should handle circular reference protection', () => {
const nodesMap: Record<string, RuntimeNodeItemType> = {
node1: {
nodeId: 'node1',
name: 'test',
flowNodeType: FlowNodeTypeEnum.chatNode,
inputs: [],
outputs: [
{
id: 'out1',
key: 'output1',
type: FlowNodeOutputTypeEnum.static,
value: '{{$node1.out1$}}',
valueType: WorkflowIOValueTypeEnum.string
}
]
}
};
const result = replaceEditorVariable({
text: 'Result: {{$node1.out1$}}',
nodesMap,
variables: {}
});
expect(result).toBe('Result: {{$node1.out1$}}');
});
it('should handle max depth protection', () => {
const result = replaceEditorVariable({
text: 'test',
nodesMap: {},
variables: {},
depth: 15
});
expect(result).toBe('test');
});
it('should handle node input as variable source', () => {
const nodesMap: Record<string, RuntimeNodeItemType> = {
node1: {
nodeId: 'node1',
name: 'test',
flowNodeType: FlowNodeTypeEnum.chatNode,
inputs: [{ key: 'myInput', label: '', renderTypeList: [], value: 'inputValue' }],
outputs: []
}
};
const result = replaceEditorVariable({
text: 'Input: {{$node1.myInput$}}',
nodesMap,
variables: {}
});
expect(result).toBe('Input: inputValue');
});
it('should convert object values to string', () => {
const nodesMap: Record<string, RuntimeNodeItemType> = {
node1: {
nodeId: 'node1',
name: 'test',
flowNodeType: FlowNodeTypeEnum.chatNode,
inputs: [],
outputs: [
{
id: 'out1',
key: 'output1',
type: FlowNodeOutputTypeEnum.static,
value: { a: 1 },
valueType: WorkflowIOValueTypeEnum.object
}
]
}
};
const result = replaceEditorVariable({
text: 'Object: {{$node1.out1$}}',
nodesMap,
variables: {}
});
expect(result).toBe('Object: {"a":1}');
});
it('should keep original pattern when node not found', () => {
const result = replaceEditorVariable({
text: '{{$nonexistent.out$}}',
nodesMap: {},
variables: {}
});
// When node is not found, the pattern is not replaced
expect(result).toBe('');
});
it('should skip duplicate variable pattern in the same text', () => {
const nodesMap: Record<string, RuntimeNodeItemType> = {
node1: {
nodeId: 'node1',
name: 'test',
flowNodeType: FlowNodeTypeEnum.chatNode,
inputs: [],
outputs: [
{
id: 'out1',
key: 'output1',
type: FlowNodeOutputTypeEnum.static,
value: 'val',
valueType: WorkflowIOValueTypeEnum.string
}
]
}
};
// Same pattern appears twice — second occurrence reuses first replacement
const result = replaceEditorVariable({
text: '{{$node1.out1$}} and {{$node1.out1$}}',
nodesMap,
variables: {}
});
expect(result).toBe('val and val');
});
it('should support Map as nodesMap', () => {
const nodesMap = new Map<string, RuntimeNodeItemType>([
[
'node1',
{
nodeId: 'node1',
name: 'test',
flowNodeType: FlowNodeTypeEnum.chatNode,
inputs: [],
outputs: [
{
id: 'out1',
key: 'output1',
type: FlowNodeOutputTypeEnum.static,
value: 'mapValue',
valueType: WorkflowIOValueTypeEnum.string
}
]
}
]
]);
const result = replaceEditorVariable({
text: 'Result: {{$node1.out1$}}',
nodesMap,
variables: {}
});
expect(result).toBe('Result: mapValue');
});
it('should handle $ special characters in variable values literally', () => {
// $& in replacement string would be interpreted as "matched substring" by JS replace()
// Using () => replacement prevents this behavior
const result1 = replaceEditorVariable({
text: `Value: {{$${VARIABLE_NODE_ID}.myVar$}}`,
nodesMap: {},
variables: { myVar: '$& some text' }
});
expect(result1).toBe('Value: $& some text');
const result2 = replaceEditorVariable({
text: `Price: {{$${VARIABLE_NODE_ID}.price$}}`,
nodesMap: {},
variables: { price: '$100' }
});
expect(result2).toBe('Price: $100');
const result3 = replaceEditorVariable({
text: `Code: {{$${VARIABLE_NODE_ID}.code$}}`,
nodesMap: {},
variables: { code: '$$' }
});
expect(result3).toBe('Code: $$');
});
});
describe('textAdaptGptResponse', () => {
it('should create GPT response format with text', () => {
const result = textAdaptGptResponse({ text: 'Hello' });
......
import { getErrText } from '@fastgpt/global/common/error/utils';
import { SYSTEM_MAX_STRING_LENGTH } from '../../env';
import { getLogger, LogCategories } from '../logger';
const VARIABLE_PLACEHOLDER_PATTERN = /\{\{([^}]+)\}\}/g;
const MAX_REPLACEMENT_DEPTH = 10;
const logger = getLogger(LogCategories.SYSTEM);
/**
* 将变量值按变量替换的历史语义转成字符串。
*
* `undefined` 输出空字符串,`null` 输出 `"null"`,对象走 JSON.stringify;
* stringify 慢或失败时保留原有日志行为,便于定位超大对象或循环引用。
*/
export const valToStr = (val: any) => {
if (val === undefined) return '';
if (val === null) return 'null';
if (typeof val === 'object') {
try {
const start = Date.now();
const res = JSON.stringify(val);
if (Date.now() - start > 1000) {
console.warn('Slow JSON.stringify', {
duration: Date.now() - start,
valLength: res.length
});
}
return res;
} catch (error) {
console.error('Failed to stringify value', { error });
return `Failed to stringify value: ${getErrText(error)}`;
}
}
return String(val);
};
export const checkStrOversize = (str: string) => str.length > SYSTEM_MAX_STRING_LENGTH;
/**
* 记录同步字符串处理遇到超长文本的情况。日志只包含长度和上下文,不输出正文,
* 避免大文本进入日志系统造成额外 CPU/IO 压力。
*/
export const logOversizeString = ({
source,
reason,
length
}: {
source: string;
reason: string;
length: number;
}) => {
logger.info('Oversize string detected during synchronous string processing', {
source,
reason,
length,
maxLength: SYSTEM_MAX_STRING_LENGTH
});
};
const hasVariableKey = (obj: Record<string, any>, key: string) => {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return Object.prototype.propertyIsEnumerable.call(obj, key);
}
if (!(key in obj)) return false;
let proto = Object.getPrototypeOf(obj);
while (proto) {
if (Object.prototype.hasOwnProperty.call(proto, key)) {
return Object.prototype.propertyIsEnumerable.call(proto, key);
}
proto = Object.getPrototypeOf(proto);
}
// Proxy-backed variable records can expose virtual keys through the `has` trap.
return true;
};
/**
* 将文本中的 `{{variable}}` 占位符替换为变量值。
*
* 替换时只格式化模板实际引用的变量,避免大变量表在每次工作流节点运行时被整体 stringify。
* 字符串长度上限统一使用 service env 初始化后得到的系统配置,不由调用方逐层传递。
*/
export const replaceVariable = (text: any, obj: Record<string, any>) => {
if (typeof text !== 'string') return text;
if (checkStrOversize(text)) {
logOversizeString({
source: 'replaceVariable',
reason: 'input',
length: text.length
});
return text;
}
if (!text.includes('{{')) return text;
const hasCircularReference = (value: any, targetKey: string): boolean => {
return typeof value === 'string' && value.includes(`{{${targetKey}}}`);
};
let result = text;
let currentDepth = 0;
while (currentDepth <= MAX_REPLACEMENT_DEPTH && result.includes('{{')) {
let changed = false;
const replacementCache = new Map<string, string | undefined>();
result = result.replace(VARIABLE_PLACEHOLDER_PATTERN, (match: string, key: string) => {
if (!hasVariableKey(obj, key)) return match;
if (replacementCache.has(key)) {
const cachedReplacement = replacementCache.get(key);
return cachedReplacement === undefined ? match : cachedReplacement;
}
const val = obj[key];
if (hasCircularReference(val, key)) {
replacementCache.set(key, undefined);
return match;
}
const replacement = valToStr(val);
replacementCache.set(key, replacement);
if (replacement !== match) {
changed = true;
}
return replacement;
});
if (checkStrOversize(result)) {
logOversizeString({
source: 'replaceVariable',
reason: 'replacement_result',
length: result.length
});
break;
}
if (!changed) break;
currentDepth++;
}
return result || '';
};
......@@ -38,8 +38,6 @@ export async function createQuestionGuide({
} = await createLLMResponse({
body: {
model,
temperature: 0.1,
max_tokens: 200,
messages: concatMessages,
stream: true,
...(questionGuideModel?.reasoning ? { reasoning_effort: 'none' as const } : {})
......
......@@ -180,7 +180,6 @@ export const queryExtension = async ({
body: {
stream: true,
model: modelData.model,
temperature: 0.1,
messages
}
});
......
......@@ -134,7 +134,6 @@ export const compressRequestMessages = async ({
content: userPrompt
}
],
temperature: 0.1,
reasoning_effort: reasoningEffort
}
});
......@@ -288,7 +287,6 @@ export const compressLargeContent = async ({
content: userPrompt
}
],
temperature: 0.1,
stream: false,
reasoning_effort: reasoningEffort
}
......
import { replaceVariable } from '@fastgpt/global/common/string/tools';
import { replaceVariable } from '../../../../common/string/replaceVariable';
import type { ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type';
export const getPromptToolCallPrompt = (tools: ChatCompletionTool['function'][]) => {
......
......@@ -234,7 +234,6 @@ export async function getSkillGuidance({
body: {
model,
messages,
max_tokens: 1000,
stream: true
}
});
......
......@@ -5,10 +5,11 @@ import { getErrText } from '@fastgpt/global/common/error/utils';
import type { RequireOnlyOne } from '@fastgpt/global/common/type/utils';
import type { HttpToolConfigType } from '@fastgpt/global/core/app/tool/httpTool/type';
import { contentTypeMap, ContentTypes } from '@fastgpt/global/core/workflow/constants';
import { replaceEditorVariable } from '@fastgpt/global/core/workflow/runtime/utils';
import { isInternalAddress, PRIVATE_URL_TEXT } from '../../common/system/utils';
import type { AppSchemaType } from '@fastgpt/global/core/app/type';
import { AppToolSourceEnum } from '@fastgpt/global/core/app/tool/constants';
import { replaceEditorVariable } from '../workflow/dispatch/utils/replaceEditorVariable';
import FormData from 'form-data';
export type RunHTTPToolParams = {
baseUrl: string;
......@@ -58,7 +59,7 @@ const buildHttpRequest = ({
}
if (staticBody.type === ContentTypes.formData) {
const formData = new (require('form-data'))();
const formData = new FormData();
staticBody.formData?.forEach(({ key, value }) => {
const replacedKey = replaceVariables(key);
const replacedValue = replaceVariables(value);
......
......@@ -60,7 +60,6 @@ export const getImageCaptionQueries = async ({
userKey,
body: {
model: vlmModelData.model,
temperature: 0.1,
stream: true,
useVision: true,
messages: [
......
......@@ -122,7 +122,6 @@ ${chunkSummaries}
body: {
model,
messages: [{ role: 'user', content: prompt }],
temperature: 0,
stream: false
}
});
......
......@@ -16,7 +16,7 @@ import {
} from '@fastgpt/global/core/chat/adapt';
import { getQuoteTemplate, getQuotePrompt } from '@fastgpt/global/core/ai/prompt/AIChat';
import type { AIChatNodeProps } from '@fastgpt/global/core/workflow/runtime/type';
import { replaceVariable } from '@fastgpt/global/common/string/tools';
import { replaceVariable } from '../../../../common/string/replaceVariable';
import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type';
import { getLLMModel } from '../../../ai/model';
import type { SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
......
......@@ -145,7 +145,6 @@ const completions = async ({
} = await createLLMResponse({
body: {
model: cqModel.model,
temperature: 0.01,
messages: chats2GPTMessages({ messages, reserveId: false, reserveReason: false }),
stream: true
},
......
......@@ -239,7 +239,6 @@ const toolChoice = async (props: ActionProps) => {
const body = {
stream: true,
model: extractModel.model,
temperature: 0.01,
messages: filterMessages,
tools,
tool_choice: { type: 'function', function: { name: agentFunName } },
......@@ -321,7 +320,6 @@ const completions = async (props: ActionProps) => {
} = await createLLMResponse({
body: {
model: extractModel.model,
temperature: 0.01,
messages: chats2GPTMessages({ messages, reserveId: false, reserveReason: false }),
stream: true
},
......
import {
replaceVariable,
sliceJsonStr,
sliceStrStartEnd
} from '@fastgpt/global/common/string/tools';
import { sliceStrStartEnd } from '@fastgpt/global/common/string/tools';
import type {
AIChatItemValueItemType,
UserChatItemValueItemType
......@@ -13,9 +9,9 @@ import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { McpToolDataType } from '@fastgpt/global/core/app/tool/mcpTool/type';
import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema';
import type { ToolNodeItemType } from './toolcall/type';
import json5 from 'json5';
import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type';
import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants';
import { replaceVariable } from '../../../../common/string/replaceVariable';
// Assistant process
export const filterToolResponseToPreview = (response: AIChatItemValueItemType[]) => {
......
......@@ -11,11 +11,7 @@ import type {
NodeOutputItemType
} from '@fastgpt/global/core/workflow/runtime/type';
import type { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
DispatchNodeResponseKeyEnum,
SseResponseEventEnum
......@@ -30,8 +26,6 @@ import { getErrText, UserError } from '@fastgpt/global/common/error/utils';
import { filterNodeResponseTreeData, stripChildTotalPoints } from '@fastgpt/global/core/chat/utils';
import {
filterWorkflowEdges,
getReferenceVariableValue,
replaceEditorVariable,
textAdaptGptResponse,
valueTypeFormat
} from '@fastgpt/global/core/workflow/runtime/utils';
......@@ -56,7 +50,7 @@ import {
summarizeRuntimeNodeResponses
} from './utils/index';
import { WorkflowVariableState } from './utils/variables';
import { getHandleId, nodeInputIsReference } from '@fastgpt/global/core/workflow/utils';
import { getHandleId } from '@fastgpt/global/core/workflow/utils';
import { callbackMap } from './constants';
import { getUserChatInfo } from '../../../support/user/team/utils';
import { checkTeamAIPoints } from '../../../support/permission/teamLimit';
......@@ -87,6 +81,7 @@ import {
shouldTraceWorkflowStep,
type WorkflowObservedStepResult
} from './utils/trace';
import { getWorkflowNodeRunParams } from './utils/runtime';
const logger = getLogger(LogCategories.MODULE.WORKFLOW.DISPATCH);
......@@ -814,66 +809,6 @@ export class WorkflowQueue {
};
const executeNode = async (stepSpan?: Span): Promise<WorkflowObservedStepResult> => {
/* Inject data into module input */
const getNodeRunParams = (node: RuntimeNodeItemType) => {
if (node.flowNodeType === FlowNodeTypeEnum.pluginInput) {
// Format plugin input to object
return node.inputs.reduce<Record<string, any>>((acc, item) => {
acc[item.key] = valueTypeFormat(item.value, item.valueType);
return acc;
}, {});
}
// Dynamic input need to store a key.
const dynamicInput = node.inputs.find(
(item) => item.renderTypeList[0] === FlowNodeInputTypeEnum.addInputParam
);
const params: Record<string, any> = dynamicInput
? {
[dynamicInput.key]: {}
}
: {};
const runtimeVariables = this.data.variableState.toRuntimeRecord();
node.inputs.forEach((input) => {
// Special input, not format
if (input.key === dynamicInput?.key) return;
// Skip some special key
if (
[NodeInputKeyEnum.childrenNodeIdList, NodeInputKeyEnum.httpJsonBody].includes(
input.key as NodeInputKeyEnum
)
) {
params[input.key] = input.value;
return;
}
// replace {{$xx.xx$}} and {{xx}} variables
let value = replaceEditorVariable({
text: input.value,
nodesMap: this.runtimeNodesMap,
variables: runtimeVariables
});
// replace reference variables
value = getReferenceVariableValue({
value,
nodesMap: this.runtimeNodesMap,
variables: runtimeVariables,
isReferenceVal: nodeInputIsReference(input)
});
// Dynamic input is stored in the dynamic key
if (input.canEdit && dynamicInput && params[dynamicInput.key]) {
params[dynamicInput.key][input.key] = valueTypeFormat(value, input.valueType);
}
params[input.key] = valueTypeFormat(value, input.valueType);
});
return params;
};
const nodeResponseId = getNanoid();
// push run status messages
......@@ -888,7 +823,11 @@ export class WorkflowQueue {
}
const startTime = Date.now();
// get node running params
const params = getNodeRunParams(node);
const params = getWorkflowNodeRunParams({
node,
runtimeNodesMap: this.runtimeNodesMap,
variableState: this.data.variableState
});
const dispatchData: ModuleDispatchProps<Record<string, any>> = {
...this.data,
......
......@@ -16,7 +16,6 @@ import type {
import {
formatVariableValByType,
getReferenceVariableValue,
replaceEditorVariable,
valueTypeFormat
} from '@fastgpt/global/core/workflow/runtime/utils';
import type { AxiosRequestConfig } from 'axios';
......@@ -29,6 +28,7 @@ import { formatHttpError } from '../utils';
import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../../common/system/utils';
import { serviceRequestMaxContentLength } from '../../../../common/system/constants';
import { axios, httpsCertificateIgnoreAgent } from '../../../../common/api/axios';
import { replaceEditorVariable } from '../utils/replaceEditorVariable';
const logger = getLogger(LogCategories.MODULE.WORKFLOW.TOOLS);
......
......@@ -6,15 +6,13 @@ import {
SseResponseEventEnum
} from '@fastgpt/global/core/workflow/runtime/constants';
import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type';
import {
getReferenceVariableValue,
replaceEditorVariable
} from '@fastgpt/global/core/workflow/runtime/utils';
import { getReferenceVariableValue } from '@fastgpt/global/core/workflow/runtime/utils';
import { type TUpdateListItem } from '@fastgpt/global/core/workflow/template/system/variableUpdate/type';
import { type ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type';
import { isValidReferenceValue } from '@fastgpt/global/core/workflow/utils';
import { valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import { replaceEditorVariable } from '../utils/replaceEditorVariable';
const addLog = getLogger(LogCategories.MODULE.WORKFLOW.DISPATCH);
......@@ -62,7 +60,7 @@ const applyNumberOp = (
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.updateList]: TUpdateListItem[];
}>;
type Response = DispatchNodeResultType<{}>;
type Response = DispatchNodeResultType<Record<string, never>>;
export const dispatchUpdateVariable = async (props: Props): Promise<Response> => {
const { params, variableState, runtimeNodesMap, workflowStreamResponse, runningAppInfo } = props;
......
......@@ -3,7 +3,7 @@ import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/
import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type';
import { replaceVariable } from '@fastgpt/global/common/string/tools';
import { replaceVariable } from '../../../../common/string/replaceVariable';
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.textareaInput]: string;
......@@ -19,25 +19,41 @@ export const dispatchTextEditor = (props: Record<string, any>): Response => {
params: { system_textareaInput: text = '', system_addInputParam: customVariables = {} }
} = props as Props;
// format variable
Object.keys(customVariables).forEach((key) => {
let val = customVariables[key];
if (!text.includes('{{')) {
return {
data: {
[NodeOutputKeyEnum.text]: text
},
[DispatchNodeResponseKeyEnum.nodeResponse]: {
textOutput: text
}
};
}
if (typeof val === 'object') {
val = JSON.stringify(val, null, 2);
} else if (typeof val === 'number') {
val = String(val);
} else if (typeof val === 'boolean') {
val = val ? 'true' : 'false';
}
const runtimeVariables = variableState.toRuntimeRecord();
const variables = new Proxy(runtimeVariables, {
has(target, key) {
if (typeof key !== 'string') return key in target;
return (
Object.prototype.hasOwnProperty.call(target, key) ||
Object.prototype.hasOwnProperty.call(customVariables, key)
);
},
get(target, key) {
if (typeof key !== 'string') return Reflect.get(target, key);
if (Object.prototype.hasOwnProperty.call(target, key)) return target[key];
if (!Object.prototype.hasOwnProperty.call(customVariables, key)) return undefined;
customVariables[key] = val;
});
const val = customVariables[key];
if (typeof val === 'object') return JSON.stringify(val, null, 2);
if (typeof val === 'number') return String(val);
if (typeof val === 'boolean') return val ? 'true' : 'false';
return val;
}
}) as Record<string, any>;
const textResult = replaceVariable(text, {
...customVariables,
...variableState.toRuntimeRecord()
});
const textResult = replaceVariable(text, variables);
return {
data: {
......
import { VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import {
formatVariableValByType,
getReferenceVariableValue
} from '@fastgpt/global/core/workflow/runtime/utils';
import {
checkStrOversize,
logOversizeString,
valToStr,
replaceVariable
} from '../../../../common/string/replaceVariable';
const NODE_VARIABLE_PATTERN = /\{\{\$([^.]+)\.([^$]+)\$\}\}/g;
const MAX_REPLACEMENT_DEPTH = 10;
/**
* 替换 workflow 编辑器文本中的普通变量和节点引用变量。
*
* 普通变量走 `{{key}}`,节点引用走 `{{$nodeId.outputId$}}`。函数只扫描模板实际出现的占位符,
* 并直接使用系统字符串长度上限,避免调度层逐层传递 max length。
*/
export function replaceEditorVariable({
text,
nodesMap,
variables
}: {
text: any;
nodesMap: Record<string, RuntimeNodeItemType> | Map<string, RuntimeNodeItemType>;
variables: Record<string, unknown>; // runtime global variables
}) {
const getNode = (nodeId: string) => {
return nodesMap instanceof Map ? nodesMap.get(nodeId) : nodesMap[nodeId];
};
if (typeof text !== 'string') return text;
if (text === '') return text;
if (checkStrOversize(text)) {
logOversizeString({
source: 'replaceEditorVariable',
reason: 'input',
length: text.length
});
return text;
}
if (!text.includes('{{')) return text;
text = replaceVariable(text, variables);
if (checkStrOversize(text)) return text;
const hasCircularReference = (value: any, targetKey: string): boolean => {
return typeof value === 'string' && value.includes(`{{$${targetKey}$}}`);
};
let result = text;
let currentDepth = 0;
while (currentDepth <= MAX_REPLACEMENT_DEPTH && result.includes('{{$')) {
let hasReplacements = false;
const replacementCache = new Map<string, string | undefined>();
result = result.replace(NODE_VARIABLE_PATTERN, (match: string, nodeId: string, id: string) => {
const variableKey = `${nodeId}.${id}`;
if (replacementCache.has(variableKey)) {
const cachedReplacement = replacementCache.get(variableKey);
return cachedReplacement === undefined ? match : cachedReplacement;
}
const variableVal = (() => {
if (nodeId === VARIABLE_NODE_ID) {
return variables[id];
}
// Find upstream node input/output
const node = getNode(nodeId);
if (!node) return;
const output = node.outputs.find((output) => output.id === id);
if (output) return formatVariableValByType(output.value, output.valueType);
// Use the node's input as the variable value(Example: HTTP data will reference its own dynamic input)
const input = node.inputs.find((input) => input.key === id);
if (input) {
return getReferenceVariableValue({
value: input.value,
nodesMap,
variables
});
}
})();
// 直接自引用保持原占位符,交给最大深度保护兜底更复杂的环。
if (hasCircularReference(variableVal, variableKey)) {
replacementCache.set(variableKey, undefined);
return match;
}
const replacement = valToStr(variableVal);
replacementCache.set(variableKey, replacement);
if (replacement !== match) {
hasReplacements = true;
}
return replacement;
});
if (!hasReplacements) break;
currentDepth++;
if (checkStrOversize(result)) {
logOversizeString({
source: 'replaceEditorVariable',
reason: 'node_reference_result',
length: result.length
});
break;
}
// 旧逻辑每次处理嵌套节点引用前都会先处理普通变量,这里保留该顺序。
if (currentDepth <= MAX_REPLACEMENT_DEPTH && result.includes('{{$')) {
result = replaceVariable(result, variables);
if (checkStrOversize(result)) break;
}
}
return result || '';
}
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import type {
RuntimeNodeItemType,
WorkflowVariableStateLike
} from '@fastgpt/global/core/workflow/runtime/type';
import {
getReferenceVariableValue,
valueTypeFormat
} from '@fastgpt/global/core/workflow/runtime/utils';
import { nodeInputIsReference } from '@fastgpt/global/core/workflow/utils';
import { replaceEditorVariable } from './replaceEditorVariable';
/**
* 解析单个工作流节点运行参数。
*
* 这是调度热路径:每个节点执行前都会经过。这里按需构造 runtime variables,
* 并在调度层跳过静态输入的文本替换,避免无变量节点也复制整张变量表。
*/
export const getWorkflowNodeRunParams = ({
node,
runtimeNodesMap,
variableState
}: {
node: RuntimeNodeItemType;
runtimeNodesMap: Map<string, RuntimeNodeItemType>;
variableState: WorkflowVariableStateLike;
}) => {
if (node.flowNodeType === FlowNodeTypeEnum.pluginInput) {
// Format plugin input to object
return node.inputs.reduce<Record<string, any>>((acc, item) => {
acc[item.key] = valueTypeFormat(item.value, item.valueType);
return acc;
}, {});
}
// Dynamic input need to store a key.
const dynamicInput = node.inputs.find(
(item) => item.renderTypeList[0] === FlowNodeInputTypeEnum.addInputParam
);
const params: Record<string, any> = dynamicInput
? {
[dynamicInput.key]: {}
}
: {};
let runtimeVariables: Record<string, unknown> | undefined;
const getRuntimeVariables = () => {
runtimeVariables ??= variableState.toRuntimeRecord();
return runtimeVariables;
};
node.inputs.forEach((input) => {
// Special input, not format
if (input.key === dynamicInput?.key) return;
// Skip some special key
if (
[NodeInputKeyEnum.childrenNodeIdList, NodeInputKeyEnum.httpJsonBody].includes(
input.key as NodeInputKeyEnum
)
) {
params[input.key] = input.value;
return;
}
const rawValue = input.value;
const isReferenceInput = nodeInputIsReference(input);
const needsTextReplace = typeof rawValue === 'string' && rawValue.includes('{{');
let value = rawValue;
if (isReferenceInput && !needsTextReplace) {
value = getReferenceVariableValue({
value,
nodesMap: runtimeNodesMap,
variables: getRuntimeVariables(),
isReferenceVal: true
});
} else {
if (needsTextReplace) {
value = replaceEditorVariable({
text: value,
nodesMap: runtimeNodesMap,
variables: getRuntimeVariables()
});
}
if (isReferenceInput) {
value = getReferenceVariableValue({
value,
nodesMap: runtimeNodesMap,
variables: getRuntimeVariables(),
isReferenceVal: true
});
}
}
// Dynamic input is stored in the dynamic key
if (input.canEdit && dynamicInput && params[dynamicInput.key]) {
params[dynamicInput.key][input.key] = valueTypeFormat(value, input.valueType);
}
params[input.key] = valueTypeFormat(value, input.valueType);
});
return params;
};
......@@ -9,6 +9,8 @@ const defaultableIntSchema = (defaultValue: number) =>
z.coerce.number<number>().int().nonnegative()
);
const SYSTEM_STRING_LENGTH_UNIT = 1_000_000;
/**
* 判断系统是否显式配置了 Agent 虚拟机能力。
* 注意 serviceEnv 会给部分字段填默认值,这里必须读取原始 env,避免把未配置误判为已配置。
......@@ -246,6 +248,10 @@ export const serviceEnv = createEnv({
WORKFLOW_PARALLEL_MAX_CONCURRENCY: IntSchema.default(10).meta({
description: '并行节点并发上限(最终会 clamp 到 [5, 100],默认 10)'
}),
SYSTEM_MAX_STRING_LENGTH_M: IntSchema.min(1).max(100).default(100).meta({
description:
'系统同步字符串处理最大字符数(M,1M=1,000,000 字符),用于变量替换等 CPU 密集文本操作'
}),
CHAT_MAX_QPM: IntSchema.default(5000).meta({
description: '聊天 QPM(若用户套餐有限制,这里不生效)'
}),
......@@ -312,3 +318,6 @@ if (serviceEnv.WORKFLOW_PARALLEL_MAX_CONCURRENCY > serviceEnv.WORKFLOW_MAX_LOOP_
`Invalid environment configuration: WORKFLOW_PARALLEL_MAX_CONCURRENCY (${serviceEnv.WORKFLOW_PARALLEL_MAX_CONCURRENCY}) must not exceed WORKFLOW_MAX_LOOP_TIMES (${serviceEnv.WORKFLOW_MAX_LOOP_TIMES})`
);
}
export const SYSTEM_MAX_STRING_LENGTH =
serviceEnv.SYSTEM_MAX_STRING_LENGTH_M * SYSTEM_STRING_LENGTH_UNIT;
......@@ -169,7 +169,6 @@ export async function updateTeam({
const response = await ai.chat.completions.create({
model: 'gpt-4o-mini',
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }]
});
if (response?.choices?.[0]?.message?.content === undefined) {
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { valToStr, replaceVariable } from '@fastgpt/service/common/string/replaceVariable';
const { loggerInfoMock } = vi.hoisted(() => ({
loggerInfoMock: vi.fn()
}));
vi.mock('@fastgpt/service/common/logger', () => ({
getLogger: () => ({
info: loggerInfoMock
}),
LogCategories: {
SYSTEM: ['system']
}
}));
describe('service replaceVariable', () => {
beforeEach(() => {
loggerInfoMock.mockClear();
});
it('should convert values to strings', () => {
expect(valToStr(undefined)).toBe('');
expect(valToStr(null)).toBe('null');
expect(valToStr({ a: 1 })).toBe('{"a":1}');
expect(valToStr(123)).toBe('123');
});
it('should replace variables with recursion and safeguards', () => {
expect(replaceVariable('Hello {{name}}', { name: 'Ada' })).toBe('Hello Ada');
expect(
replaceVariable('Hello {{name}}', {
name: '{{first}} {{last}}',
first: 'Ada',
last: 'Lovelace'
})
).toBe('Hello Ada Lovelace');
expect(replaceVariable('Hello {{name}}', { name: undefined })).toBe('Hello ');
expect(replaceVariable('Hello {{name}}', { name: '{{name}}' })).toBe('Hello {{name}}');
expect(replaceVariable(123 as any, { name: 'Ada' })).toBe(123);
});
it('should only stringify variables that appear in the template', () => {
let stringifyCount = 0;
const unusedLargeObject = {
toJSON() {
stringifyCount += 1;
return { value: 'unused' };
}
};
expect(
replaceVariable('Hello {{name}}', {
name: 'Ada',
unusedLargeObject
})
).toBe('Hello Ada');
expect(stringifyCount).toBe(0);
});
it('should return strings without placeholders before reading variables', () => {
let stringifyCount = 0;
const unused = {
toJSON() {
stringifyCount += 1;
return { value: 'unused' };
}
};
expect(replaceVariable('Hello Ada', { unused })).toBe('Hello Ada');
expect(stringifyCount).toBe(0);
});
it('should stop replacement rounds when the result exceeds the system string limit', async () => {
vi.resetModules();
vi.doMock('@fastgpt/service/env', () => ({
SYSTEM_MAX_STRING_LENGTH: 20
}));
const { replaceVariable: replaceVariableWithSmallLimit } =
await import('@fastgpt/service/common/string/replaceVariable');
expect(
replaceVariableWithSmallLimit('{{large}}', {
large: `${'x'.repeat(21)}{{next}}`,
next: 'should not be scanned'
})
).toBe(`${'x'.repeat(21)}{{next}}`);
expect(loggerInfoMock).toHaveBeenCalledWith(
'Oversize string detected during synchronous string processing',
{
source: 'replaceVariable',
reason: 'replacement_result',
length: 29,
maxLength: 20
}
);
loggerInfoMock.mockClear();
expect(replaceVariableWithSmallLimit(`${'x'.repeat(21)}{{next}}`, { next: 'value' })).toBe(
`${'x'.repeat(21)}{{next}}`
);
expect(loggerInfoMock).toHaveBeenCalledWith(
'Oversize string detected during synchronous string processing',
{
source: 'replaceVariable',
reason: 'input',
length: 29,
maxLength: 20
}
);
vi.doUnmock('@fastgpt/service/env');
vi.resetModules();
});
it('should stringify the same referenced variable once per replacement round', () => {
let stringifyCount = 0;
const value = {
toJSON() {
stringifyCount += 1;
return { a: 1 };
}
};
expect(replaceVariable('{{value}} {{value}}', { value })).toBe('{"a":1} {"a":1}');
expect(stringifyCount).toBe(1);
});
it('should only replace enumerable own, inherited, and proxy-backed keys', () => {
expect(replaceVariable('value: {{toString}}', {})).toBe('value: {{toString}}');
expect(replaceVariable('value: {{toString}}', { toString: 'own value' })).toBe(
'value: own value'
);
const nonEnumerableOwn = {};
Object.defineProperty(nonEnumerableOwn, 'secret', {
value: 'hidden',
enumerable: false
});
expect(replaceVariable('value: {{secret}}', nonEnumerableOwn)).toBe('value: {{secret}}');
const enumerablePrototype = {
inheritedName: 'Ada'
};
expect(replaceVariable('Hello {{inheritedName}}', Object.create(enumerablePrototype))).toBe(
'Hello Ada'
);
const nonEnumerablePrototype = {};
Object.defineProperty(nonEnumerablePrototype, 'inheritedSecret', {
value: 'hidden',
enumerable: false
});
expect(
replaceVariable('value: {{inheritedSecret}}', Object.create(nonEnumerablePrototype))
).toBe('value: {{inheritedSecret}}');
const variables = new Proxy(
{},
{
has(_, key) {
return key === 'name';
},
get(_, key) {
return key === 'name' ? 'Ada' : undefined;
}
}
) as Record<string, any>;
expect(replaceVariable('Hello {{name}}', variables)).toBe('Hello Ada');
});
it('should treat $ special characters in replacement value as literals', () => {
expect(replaceVariable('value: {{val}}', { val: '$1' })).toBe('value: $1');
expect(replaceVariable('value: {{val}}', { val: '$2' })).toBe('value: $2');
expect(replaceVariable('value: {{val}}', { val: '$$' })).toBe('value: $$');
expect(replaceVariable('value: {{val}}', { val: '$&' })).toBe('value: $&');
expect(replaceVariable('value: {{val}}', { val: "$'" })).toBe("value: $'");
expect(replaceVariable('value: {{val}}', { val: '$`' })).toBe('value: $`');
expect(replaceVariable('result={{a}}&other={{b}}', { a: '$1', b: '$2' })).toBe(
'result=$1&other=$2'
);
});
});
......@@ -489,11 +489,11 @@ describe('compressLargeContent', () => {
expect(createLLMResponseMock).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
stream: false,
temperature: 0.1
stream: false
})
})
);
expect(createLLMResponseMock.mock.calls[0][0].body).not.toHaveProperty('temperature');
const compressPrompt = createLLMResponseMock.mock.calls[0][0].body.messages[0].content;
const userPrompt = createLLMResponseMock.mock.calls[0][0].body.messages[1].content;
expect(compressPrompt).not.toContain('tokens');
......
......@@ -7,8 +7,19 @@ import {
WorkflowQueue,
mergeAssistantResponseAnswerText
} from '@fastgpt/service/core/workflow/dispatch/index';
import { getWorkflowNodeRunParams } from '@fastgpt/service/core/workflow/dispatch/utils/runtime';
import { createClientAbortTracker } from '@fastgpt/service/core/workflow/dispatch/utils/clientAbort';
import { createNode, createEdge } from '../utils';
import {
NodeInputKeyEnum,
VARIABLE_NODE_ID,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import {
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import type { WorkflowVariableStateLike } from '@fastgpt/global/core/workflow/runtime/type';
const waitWithTimeout = async <T>(promise: Promise<T>, timeoutMs: number, label: string) => {
let timer: ReturnType<typeof setTimeout> | undefined;
......@@ -412,6 +423,171 @@ describe('createClientAbortTracker', () => {
});
});
describe('getWorkflowNodeRunParams', () => {
const createVariableState = (variables: Record<string, unknown> = {}) => {
let toRuntimeRecordCount = 0;
const state: WorkflowVariableStateLike = {
get: (key) => variables[key],
set: async (key, value) => {
variables[key] = value;
return value;
},
getStoreValue: (key) => variables[key],
getFileStoreValueByRuntimeUrl: () => undefined,
toRuntimeRecord: () => {
toRuntimeRecordCount += 1;
return { ...variables };
},
toStoreRecord: () => ({ ...variables }),
clone: () => createVariableState({ ...variables }).state
};
return {
state,
getToRuntimeRecordCount: () => toRuntimeRecordCount
};
};
it('静态 input 不应构造 runtimeVariables', () => {
const variableState = createVariableState({ name: 'Ada' });
const node = createNode('node1', FlowNodeTypeEnum.textEditor);
node.inputs = [
{
key: NodeInputKeyEnum.textareaInput,
label: '',
renderTypeList: [FlowNodeInputTypeEnum.textarea],
value: 'plain text',
valueType: WorkflowIOValueTypeEnum.string
}
];
const params = getWorkflowNodeRunParams({
node,
runtimeNodesMap: new Map(),
variableState: variableState.state
});
expect(params[NodeInputKeyEnum.textareaInput]).toBe('plain text');
expect(variableState.getToRuntimeRecordCount()).toBe(0);
});
it('普通变量模板按需构造 runtimeVariables 并完成替换', () => {
const variableState = createVariableState({ name: 'Ada' });
const node = createNode('node1', FlowNodeTypeEnum.textEditor);
node.inputs = [
{
key: NodeInputKeyEnum.textareaInput,
label: '',
renderTypeList: [FlowNodeInputTypeEnum.textarea],
value: 'Hello {{name}}',
valueType: WorkflowIOValueTypeEnum.string
}
];
const params = getWorkflowNodeRunParams({
node,
runtimeNodesMap: new Map(),
variableState: variableState.state
});
expect(params[NodeInputKeyEnum.textareaInput]).toBe('Hello Ada');
expect(variableState.getToRuntimeRecordCount()).toBe(1);
});
it('节点输出模板按需构造 runtimeVariables 并完成替换', () => {
const variableState = createVariableState({
unused: {
toJSON() {
throw new Error('unused variable should not be stringified');
}
}
});
const node = createNode('target', FlowNodeTypeEnum.textEditor);
node.inputs = [
{
key: NodeInputKeyEnum.textareaInput,
label: '',
renderTypeList: [FlowNodeInputTypeEnum.textarea],
value: 'Result: {{$source.output$}}',
valueType: WorkflowIOValueTypeEnum.string
}
];
const sourceNode = createNode('source', FlowNodeTypeEnum.textEditor);
sourceNode.outputs = [
{
id: 'output',
key: 'output',
type: FlowNodeOutputTypeEnum.static,
value: 'done',
valueType: WorkflowIOValueTypeEnum.string
}
];
const params = getWorkflowNodeRunParams({
node,
runtimeNodesMap: new Map([['source', sourceNode]]),
variableState: variableState.state
});
expect(params[NodeInputKeyEnum.textareaInput]).toBe('Result: done');
expect(variableState.getToRuntimeRecordCount()).toBe(1);
});
it('纯引用 input 直接解析原始对象', () => {
const refValue = { nested: true };
const variableState = createVariableState({ payload: refValue });
const node = createNode('node1', FlowNodeTypeEnum.textEditor);
node.inputs = [
{
key: 'payload',
label: '',
renderTypeList: [FlowNodeInputTypeEnum.reference],
value: [VARIABLE_NODE_ID, 'payload'],
valueType: WorkflowIOValueTypeEnum.object
}
];
const params = getWorkflowNodeRunParams({
node,
runtimeNodesMap: new Map(),
variableState: variableState.state
});
expect(params.payload).toBe(refValue);
expect(variableState.getToRuntimeRecordCount()).toBe(1);
});
it('dynamic input 保持顶层和动态参数对象同步写入', () => {
const variableState = createVariableState({ name: 'Ada' });
const node = createNode('node1', FlowNodeTypeEnum.textEditor);
node.inputs = [
{
key: NodeInputKeyEnum.addInputParam,
label: '',
renderTypeList: [FlowNodeInputTypeEnum.addInputParam],
value: undefined
},
{
key: 'dynamicName',
label: '',
renderTypeList: [FlowNodeInputTypeEnum.input],
value: '{{name}}',
valueType: WorkflowIOValueTypeEnum.string,
canEdit: true
}
];
const params = getWorkflowNodeRunParams({
node,
runtimeNodesMap: new Map(),
variableState: variableState.state
});
expect(params[NodeInputKeyEnum.addInputParam]).toEqual({ dynamicName: 'Ada' });
expect(params.dynamicName).toBe('Ada');
});
});
describe('WorkflowQueue', () => {
describe('WorkflowQueue utils', () => {
// buildNodeEdgeGroupsMap 已经单独写了
......
import { describe, expect, it } from 'vitest';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { dispatchTextEditor } from '@fastgpt/service/core/workflow/dispatch/tools/textEditor';
import type { WorkflowVariableStateLike } from '@fastgpt/global/core/workflow/runtime/type';
const createVariableState = (
variables: Record<string, unknown> = {}
): WorkflowVariableStateLike => ({
get: (key) => variables[key],
set: async (key, value) => {
variables[key] = value;
return value;
},
getStoreValue: (key) => variables[key],
getFileStoreValueByRuntimeUrl: () => undefined,
toRuntimeRecord: () => ({ ...variables }),
toStoreRecord: () => ({ ...variables }),
clone: () => createVariableState({ ...variables })
});
describe('dispatchTextEditor', () => {
it('纯文本不构造 runtime variables', () => {
const result = dispatchTextEditor({
variableState: {
...createVariableState(),
toRuntimeRecord: () => {
throw new Error('should not build runtime variables');
}
},
params: {
[NodeInputKeyEnum.textareaInput]: 'plain text',
[NodeInputKeyEnum.addInputParam]: {}
}
});
expect(result.data?.[NodeOutputKeyEnum.text]).toBe('plain text');
expect(result[DispatchNodeResponseKeyEnum.nodeResponse]?.textOutput).toBe('plain text');
});
it('只 stringify 模板实际引用的 custom variable', () => {
let stringifyCount = 0;
const unusedObject = {
toJSON() {
stringifyCount += 1;
return { value: 'unused' };
}
};
const result = dispatchTextEditor({
variableState: createVariableState(),
params: {
[NodeInputKeyEnum.textareaInput]: 'Hello {{name}}',
[NodeInputKeyEnum.addInputParam]: {
name: 'Ada',
unusedObject
}
}
});
expect(result.data?.[NodeOutputKeyEnum.text]).toBe('Hello Ada');
expect(result[DispatchNodeResponseKeyEnum.nodeResponse]?.textOutput).toBe('Hello Ada');
expect(stringifyCount).toBe(0);
});
it('保持 custom variable object 的 pretty JSON 输出兼容行为', () => {
const result = dispatchTextEditor({
variableState: createVariableState(),
params: {
[NodeInputKeyEnum.textareaInput]: '{{payload}}',
[NodeInputKeyEnum.addInputParam]: {
payload: { a: 1 }
}
}
});
expect(result.data?.[NodeOutputKeyEnum.text]).toBe('{\n "a": 1\n}');
});
it('不把 Object 原型字段误当作变量', () => {
const result = dispatchTextEditor({
variableState: createVariableState(),
params: {
[NodeInputKeyEnum.textareaInput]: '{{toString}}',
[NodeInputKeyEnum.addInputParam]: {}
}
});
expect(result.data?.[NodeOutputKeyEnum.text]).toBe('{{toString}}');
expect(result[DispatchNodeResponseKeyEnum.nodeResponse]?.textOutput).toBe('{{toString}}');
});
});
import { afterEach, describe, expect, it, vi } from 'vitest';
const originalEnv = {
SYSTEM_MAX_STRING_LENGTH_M: process.env.SYSTEM_MAX_STRING_LENGTH_M,
FILE_TOKEN_KEY: process.env.FILE_TOKEN_KEY,
AES256_SECRET_KEY: process.env.AES256_SECRET_KEY
};
const importServiceEnv = async () => {
vi.resetModules();
const { serviceEnv, SYSTEM_MAX_STRING_LENGTH } = await import('@fastgpt/service/env');
return { serviceEnv, SYSTEM_MAX_STRING_LENGTH };
};
describe('serviceEnv', () => {
afterEach(() => {
vi.stubEnv('SYSTEM_MAX_STRING_LENGTH_M', originalEnv.SYSTEM_MAX_STRING_LENGTH_M);
vi.stubEnv('FILE_TOKEN_KEY', originalEnv.FILE_TOKEN_KEY);
vi.stubEnv('AES256_SECRET_KEY', originalEnv.AES256_SECRET_KEY);
});
it('validates SYSTEM_MAX_STRING_LENGTH_M during service env init', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
vi.stubEnv('SYSTEM_MAX_STRING_LENGTH_M', undefined);
await expect(importServiceEnv()).resolves.toMatchObject({
SYSTEM_MAX_STRING_LENGTH: 100_000_000,
serviceEnv: {
SYSTEM_MAX_STRING_LENGTH_M: 100
}
});
vi.stubEnv('SYSTEM_MAX_STRING_LENGTH_M', '2');
await expect(importServiceEnv()).resolves.toMatchObject({
SYSTEM_MAX_STRING_LENGTH: 2_000_000,
serviceEnv: {
SYSTEM_MAX_STRING_LENGTH_M: 2
}
});
});
it('rejects invalid SYSTEM_MAX_STRING_LENGTH_M during service env init', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
vi.stubEnv('SYSTEM_MAX_STRING_LENGTH_M', '0');
await expect(importServiceEnv()).rejects.toThrow('Invalid environment variables');
vi.stubEnv('SYSTEM_MAX_STRING_LENGTH_M', '101');
await expect(importServiceEnv()).rejects.toThrow('Invalid environment variables');
vi.stubEnv('SYSTEM_MAX_STRING_LENGTH_M', 'not-a-number');
await expect(importServiceEnv()).rejects.toThrow('Invalid environment variables');
});
});
......@@ -257,6 +257,10 @@
"logs_source": "source",
"logs_source_count": "Channel users",
"logs_source_count_description": "Number of users across channels",
"logs_timespan_day": "Day",
"logs_timespan_week": "Week",
"logs_timespan_month": "Month",
"logs_timespan_quarter": "Quarter",
"logs_title": "Title",
"logs_total": "Grand total",
"logs_total_avg_duration": "Avg. duration",
......@@ -488,13 +492,13 @@
"toolkit_tool_config": "{{name}} Configuration",
"toolkit_tool_list": "Tool List",
"toolkit_tool_name": "Tool Name",
"toolkit_tutorial_link": "Open tutorial link",
"toolkit_uninstall": "Uninstall",
"toolkit_uninstalled": "Uninstalled",
"toolkit_updatable": "Updates Available",
"toolkit_updatable_plugins": "Updatable Plugins",
"toolkit_update_failed": "Update failed",
"toolkit_user_guide": "User Guide",
"toolkit_tutorial_link": "Open tutorial link",
"toolkit_version_description": "Version Description",
"toolkit_version_info": "Version Information",
"tools": "tool",
......
......@@ -812,7 +812,7 @@
"error_llm_not_config": "Unconfigured file understanding model",
"error_un_permission": "No permission to operate",
"error_vlm_not_config": "Image comprehension model not configured",
"exit_directly": "exit_directly",
"exit_directly": "Exit",
"expired_time": "Expiration Time",
"export_to_json": "Export to JSON",
"extraPointsPrice": "¥ {{price}}",
......
......@@ -257,6 +257,10 @@
"logs_source": "来源",
"logs_source_count": "渠道用户",
"logs_source_count_description": "各渠道用户的数量",
"logs_timespan_day": "按天",
"logs_timespan_week": "按周",
"logs_timespan_month": "按月",
"logs_timespan_quarter": "按季度",
"logs_title": "标题",
"logs_total": "累计",
"logs_total_avg_duration": "平均时长",
......@@ -488,13 +492,13 @@
"toolkit_tool_config": "{{name}}配置",
"toolkit_tool_list": "工具列表",
"toolkit_tool_name": "工具名",
"toolkit_tutorial_link": "查看教程链接",
"toolkit_uninstall": "卸载",
"toolkit_uninstalled": "未安装",
"toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的插件",
"toolkit_update_failed": "更新失败",
"toolkit_user_guide": "使用说明",
"toolkit_tutorial_link": "查看教程链接",
"toolkit_version_description": "版本描述",
"toolkit_version_info": "版本信息",
"tools": "工具",
......
......@@ -251,6 +251,10 @@
"logs_search_placeholder": "搜尋標題/會話ID",
"logs_source": "來源",
"logs_source_count_description": "各渠道用戶的數量",
"logs_timespan_day": "按天",
"logs_timespan_week": "按週",
"logs_timespan_month": "按月",
"logs_timespan_quarter": "按季度",
"logs_title": "標題",
"logs_total": "累計",
"logs_total_avg_points": "平均消耗",
......@@ -476,13 +480,13 @@
"toolkit_tool_config": "{{name}}配置",
"toolkit_tool_list": "工具列表",
"toolkit_tool_name": "工具名",
"toolkit_tutorial_link": "查看教學連結",
"toolkit_uninstall": "卸載",
"toolkit_uninstalled": "未安裝",
"toolkit_updatable": "可更新",
"toolkit_updatable_plugins": "可更新的外掛程式",
"toolkit_update_failed": "更新失敗",
"toolkit_user_guide": "使用說明",
"toolkit_tutorial_link": "查看教學連結",
"toolkit_version_description": "版本描述",
"toolkit_version_info": "版本資訊",
"tools": "工具",
......
Subproject commit 38be4a68fbc7eecc4ef86d421aba5898ec014201
Subproject commit 298aa5c17c853e98ff15672721acb343b3e8b365
......@@ -11,11 +11,7 @@ import { useContextSelector } from 'use-context-selector';
import { ChatBoxContext } from '../../Provider';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import AIChatLoading from '../AIChatLoading';
import {
hasAiAnswerContent,
hasAiInteractiveContent,
hasAiProcessingContent
} from './utils';
import { hasAiAnswerContent, hasAiInteractiveContent, hasAiProcessingContent } from './utils';
import { useTranslation } from 'next-i18next';
const ResponseTags = dynamic(() => import('../ResponseTags'));
......@@ -92,7 +88,7 @@ const AIChatBubble = ({
lineHeight="20px"
color="myGray.500"
>
{t('chat:no_output_content', '应用无输出内容')}
{t('chat:no_output_content')}
</Box>
)}
{isLastValueGroup && (
......
......@@ -19,7 +19,6 @@ import AIChatBubble, { shouldFilterAiValue } from './AIChatBubble';
import type { ChatBoxInputType } from '../type';
import { hasAiAnswerContent } from './AIChatBubble/utils';
import ChatErrorCard from './ChatErrorCard';
import { getErrText } from '@fastgpt/global/common/error/utils';
const colorMap = {
[ChatStatusEnum.loading]: {
......@@ -87,16 +86,10 @@ const ChatItem = (props: Props) => {
errorText?.moduleName ||
chat.moduleName ||
t('common:core.module.template.ai_chat', { defaultValue: 'AI 对话' });
const errorReason = errorText?.errorText || '';
const noOutputText = t('chat:no_output', '无输出');
const isNoOutputError =
!errorReason ||
errorReason === 'chat:LLM_model_response_empty' ||
errorReason === t('chat:LLM_model_response_empty');
return {
title: `${t('chat:log.error.error_prefix')} - ${t(moduleName)}`,
message: isNoOutputError ? noOutputText : t(getErrText(errorReason))
message: t(errorText?.errorText || chat.errorMsg || 'Unknow error')
};
}, [chat.errorMsg, chat.moduleName, errorText, t]);
......@@ -354,7 +347,6 @@ const ChatItem = (props: Props) => {
</Box>
);
})}
</Flex>
);
};
......
......@@ -9,6 +9,7 @@ import type { SearchDataResponseQuoteListItemType } from '@fastgpt/global/core/d
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { getFlatAppResponses } from '@fastgpt/global/core/chat/utils';
import { sandboxToolMap } from '@fastgpt/global/core/ai/sandbox/tools';
import { getErrText } from '@fastgpt/global/common/error/utils';
export const isLLMNode = (item: ChatHistoryItemResType) =>
item.moduleType === FlowNodeTypeEnum.chatNode || item.moduleType === FlowNodeTypeEnum.toolCall;
......@@ -16,6 +17,16 @@ export const isLLMNode = (item: ChatHistoryItemResType) =>
const isSandboxToolId = (toolId?: string) =>
!!toolId && Object.prototype.hasOwnProperty.call(sandboxToolMap, toolId);
/**
* 从节点运行详情中提取可直接展示在聊天气泡上的错误文本。
*
* 部分节点失败时历史数据只写入顶层 `error`,不一定有专门用于展示的 `errorText`。
* 这里统一兜底,避免报错卡片显示“无输出”。
*/
const getNodeErrorText = (item: ChatHistoryItemResType) => {
return item.errorText || getErrText(item.error);
};
export function transformPreviewHistories(
histories: ChatItemMiniType[],
responseDetail: boolean
......@@ -100,10 +111,11 @@ export function addStatisticalDataToHistoryItem(historyItem: ChatItemMiniType) {
}
}
if (item.errorText && !acc.errorText) {
const nodeErrorText = getNodeErrorText(item);
if (nodeErrorText && !acc.errorText) {
acc.errorText = {
moduleName: item.moduleName,
errorText: item.errorText
errorText: nodeErrorText
};
}
......
......@@ -36,6 +36,7 @@ import BarChartComponent from '@fastgpt/web/components/common/charts/BarChartCom
import { theme } from '@fastgpt/web/styles/theme';
import MySelect from '@fastgpt/web/components/common/MySelect';
import {
AppLogTimespanMap,
AppLogTimespanEnum,
fakeChartData,
offsetOptions
......@@ -364,7 +365,7 @@ const LogChart = ({
<Flex flex={1} />
<MySelect
list={Object.values(AppLogTimespanEnum).map((option) => ({
label: t(`app:logs_timespan_${option}`),
label: t(AppLogTimespanMap[option].label),
value: option
}))}
value={userTimespan}
......@@ -519,7 +520,7 @@ const LogChart = ({
<Flex flex={1} />
<MySelect
list={Object.values(AppLogTimespanEnum).map((option) => ({
label: t(`app:logs_timespan_${option}`),
label: t(AppLogTimespanMap[option].label),
value: option
}))}
value={chatTimespan}
......@@ -667,7 +668,7 @@ const LogChart = ({
<Flex flex={1} />
<MySelect
list={Object.values(AppLogTimespanEnum).map((option) => ({
label: t(`app:logs_timespan_${option}`),
label: t(AppLogTimespanMap[option].label),
value: option
}))}
value={appTimespan}
......
......@@ -103,8 +103,6 @@ async function handler(req: ApiRequestProps<OptimizePromptBody>, res: ApiRespons
body: {
model,
messages,
temperature: 0.1,
max_tokens: 2000,
stream: true
},
onStreaming: ({ text }) => {
......
......@@ -113,8 +113,6 @@ async function handler(req: ApiRequestProps<OptimizeCodeBody>, res: ApiResponseT
body: {
model,
messages,
temperature: 0.1,
max_tokens: 2000,
stream: true,
useVision: false
},
......
......@@ -3,7 +3,7 @@ import { pushLLMTrainingUsage } from '@fastgpt/service/support/wallet/usage/cont
import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants';
import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type';
import { getLogger, LogCategories } from '@fastgpt/service/common/logger';
import { replaceVariable } from '@fastgpt/global/common/string/tools';
import { replaceVariable } from '@fastgpt/service/common/string/replaceVariable';
import { Prompt_AgentQA } from '@fastgpt/global/core/ai/prompt/agent';
import type { PushDataChunkType } from '@fastgpt/global/openapi/core/dataset/data/api';
import { getLLMModel } from '@fastgpt/service/core/ai/model';
......@@ -143,7 +143,6 @@ export async function generateQA(): Promise<any> {
} = await createLLMResponse({
body: {
model: modelData.model,
temperature: 0.3,
messages,
stream: true
}
......
......@@ -160,6 +160,65 @@ describe('addStatisticalDataToHistoryItem', () => {
expect(addStatisticalDataToHistoryItem(historyItem).useAgentSandbox).toBe(false);
});
it('uses node error as chat bubble error text when errorText is absent', () => {
const historyItem: ChatItemMiniType = {
obj: ChatRoleEnum.AI,
value: [
{
text: {
content: 'done'
}
}
],
responseData: [
{
id: 'http-response',
nodeId: 'http-node',
moduleName: 'HTTP 请求',
moduleType: FlowNodeTypeEnum.httpRequest468,
error: 'connect ECONNREFUSED 127.0.0.1:3000'
}
]
};
expect(addStatisticalDataToHistoryItem(historyItem).errorText).toEqual({
moduleName: 'HTTP 请求',
errorText: 'connect ECONNREFUSED 127.0.0.1:3000'
});
});
it('does not use HTTP result error as chat bubble error text when node error is absent', () => {
const historyItem: ChatItemMiniType = {
obj: ChatRoleEnum.AI,
value: [
{
text: {
content: 'done'
}
}
],
responseData: [
{
id: 'http-response',
nodeId: 'http-node',
moduleName: 'HTTP 请求',
moduleType: FlowNodeTypeEnum.httpRequest468,
httpResult: {
error: {
message: 'Request failed with status code 500',
status: 500,
data: {
message: 'upstream failed'
}
}
}
}
]
};
expect(addStatisticalDataToHistoryItem(historyItem).errorText).toBeUndefined();
});
it('includes dataset quote tags that use QUOTE markdown links', () => {
const quoteId = '507f1f77bcf86cd799439011';
const historyItem: ChatItemMiniType = {
......
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